Make it True — Разработка логической игры на Unity

в 19:32, , рубрики: .net, C#, dependency injection, Gamedev, indie, indie gamedev, longread, unity, zenject, логические игры, мобильные игры, разработка игр

Make it True — Разработка логической игры на Unity - 1

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

Статья не являются руководством или уроком, хотя надеюсь что читатели смогут вынести что то полезное из нее. Рассчитано на разработчиков знакомых с Unity имеющих некоторый опыт в программировании.

Содержание:

Идея
Геймплей
Сюжет
Разработка
Core

  1. Электрические элементы
  2. Solver
  3. ElementsProvider
  4. CircuitGenerator

Игровые классы

  1. Подход к разработке и DI
  2. Конфигурация
  3. Электрические элементы
  4. Game Management
  5. Загрузка уровней
  6. Катсцены
  7. Дополнительный геймплей
  8. Монетизация
  9. Пользовательский интерфейс
  10. Аналитика
  11. Позиционирование камеры и схемы
  12. Цветовые схемы

Расширения редактора

  1. Generator
  2. Solver

Полезное

  1. AssertHelper
  2. SceneObjectsHelper
  3. CoroutineStarter
  4. Gizmo

Тестирование
Итоги разработки

Идея

Содержание

Появилась идея сделать простую мобильную игру, за короткий период.

Условия:

  • Простая в реализации игра
  • Минимальные требования к арту
  • Небольшое время разработки (несколько месяцев)
  • С легкой автоматизацией создания контента (уровней, локаций, игровых элементов)
  • Быстрое создание уровня, если игра состоит из конечного количества уровней

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

К выше указанным пунктам добавляются:

  • Игра должна иметь определенную популярность у игроков(количество загрузок + оценки)
  • Магазин приложений не должен быть переполнен похожими играми

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

Геймплей игры состоит в том, что уровень представляет собой цифровую схему с множеством входов и выходов. Игрок должен подобрать такую комбинацию входов, чтобы на выходе была логическая 1. Звучит не очень сложно. Также в игре есть автоматически генерируемые уровни, что подсказывает что возможность автоматизации создания уровней, хоть и звучит не очень просто. Игра также хороша для обучения, что очень мне понравилось.

Плюсы:

  • Техническая простота геймплея
  • Выглядит легко тестируемой автотестами
  • Возможность автогенерации уровней

Минусы:

  • Необходимо предварительно создавать уровни

Теперь исследуем недостатки игры которой вдохновились.

  • Не адаптирована под нестандартное соотношение сторон, вроде 18:9
  • Нет возможности пропустить сложный уровень или получить подсказку
  • В отзывах были встречены жалобы на малое количество уровней
  • В отзывах жаловались на недостаток разнообразия элементов

Переходим к планированию нашей игры:

  • Используем стандартные логические вентили (AND, NAND, OR, NOR, XOR, XNOR, NOR, NOT)
  • Вентили отображаем картинкой вместо текстового обозначения, что проще для различия. Поскольку элементы имеют стандартные обозначения ANSI используем их.
  • Отбрасываем переключатель который подключает один вход к одному из выходов. По причине того, что он требует нажимать на себя и немного не вписывается в настоящие цифровые элементы. Да и сложно себе представить тумблер в микросхеме.
  • Добавляем элементы Шифратор и Дешифратор.
  • Вводим режим в котором игрок должен подбирать нужный элемент в ячейке с фиксированными значениями на входах схемы.
  • Реализуем помощь игроку: подсказка + пропуск уровня.
  • Хорошо бы добавить некоторый сюжет.

Геймплей

Содержание

Режим 1: Игрок получает схему и имеет доступ к изменению значений на входах.
Режим 2: Игрок получает схему в которой может поменять элементы но не может поменять значения на входах.

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

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

Сюжет

Содержание

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

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

Идея! Инженер разрабатывает прикольного робота при помощи своих логических схем. Робот довольно простая понятная вещь и отлично вяжется с геймплеем.

Помните первый пункт “Минимальные требования к арту”? Что то не вяжется с катсценами в сюжете. Тут на помощь приходит знакомая художница, которая согласилась подсобить нам.

Теперь определимся с форматом и интеграцией катсцен в игру.

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

Катсцены и уровни должны быть раздельными сценами. Перед определённым уровнем загружается определенная сцена.

Отлично, задача поставлена, ресурсы на выполнение есть, работа закипела.

Разработка

Содержание

С платформой определился сразу, это Unity. Да немного overkill, но тем не менее я с ней знаком.

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

Core

Содержание

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

Юнити работает с C# решением и проектами внутри немного непривычно для обычного .Net разработчика, файлы .sln и .csproj генерируются самим Unity и изменения внутри этих файлах не принимаются к рассмотрению на стороне Unity. Он их просто перезапишет и удалит все изменения. Для создания нового проекта необходимо использовать Assembly Definition файл.

Make it True — Разработка логической игры на Unity - 2

Make it True — Разработка логической игры на Unity - 3

Теперь Unity генерирует проект с соответствующим названием. Все что лежит в папке с .asmdef файлом будет относится к этому проекту и сборке.

Электрические элементы

Содержание

Стоит задача описать в коде взаимодействие логических элементов друг с другом.

  • У элемента может быть множество входов и множество выходов
  • Вход элемента должен подключаться к выходу другого элемента
  • Сам элемент должен содержать свою логику

Приступим.

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

Make it True — Разработка логической игры на Unity - 4

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

public interface IConnector
{
    bool Value { get; }
}

Только как его подключить к другому коннектору?

Определим еще интерфейсы.

public interface IInputConnector : IConnector
{
   IOutputConnector ConnectedOtherConnector { get; set; }
}

IInputConnector является коннектором на входе, он имеет ссылку на другой коннектор.

public interface IOutputConnector : IConnector
{
   IElectricalElement Element { set; get; }
}

Коннектор на выходе ссылается на свой элемент у которого он запросит значение.

public interface IElectricalElement
{
    bool GetValue(byte number = 0);
}

Электрический элемент должен содержать метод который возвращает значение на определенном выходе, number — это номер выхода.

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

Теперь перейдем к реализации

public class InputConnector : IInputConnector
{
        public IOutputConnector ConnectedOtherConnector { get; set; }

        public bool Value
        {
            get
            {                
                return ConnectedOtherConnector?.Value ?? false;
            }
        }
}

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

public class OutputConnector : IOutputConnector
{
        private readonly byte number;

        public OutputConnector(byte number = 0)
        {
            this.number = number;
        }

        public IElectricalElement Element { get; set; }
        public bool Value => Element.GetValue(number);
    }
}

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

public abstract class ElectricalElementBase
{
        public IInputConnector[] Input { get; set; }
}

Базовый класс для всех элементов, просто содержит массив входов.

Пример реализации элемента:

public class And : ElectricalElementBase, IElectricalElement
{
        public bool GetValue(byte number = 0)
        {
            bool outputValue = false;
            if (Input?.Length > 0)
            {
                outputValue = Input[0].Value;

                foreach (var item in Input)
                {
                    outputValue &= item.Value;
                }
            }

            return outputValue;
        }
}

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

Инвертированные элементы выполнены следующим образом:

public class Nand : And, IElectricalElement
{
        public new bool GetValue(byte number = 0)
        {
            return !base.GetValue(number);
        }
}

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

Кроме обычных вентилей были созданы такие элементы:
Source — источник постоянного значения 0 или 1.
Conductor — просто проводник тот же Or, только имеет немного иное применение, см. генерацию.
AlwaysFalse — всегда возвращает 0, нужно для второго режима.

Solver

Содержание

Далее пригодится класс для автоматического нахождения комбинаций которые на выходе схемы дают 1.

    public interface ISolver
    {
        ICollection<bool[]> GetSolutions(IElectricalElement root, params Source[] sources);
    }

public class Solver : ISolver
    {
        public ICollection<bool[]> GetSolutions(IElectricalElement root, params Source[] sources)
        {
            // max value can be got with this count of bits(sources count), also it's count of combinations -1
            // for example 8 bits provide 256 combinations, and max value is 255
            int maxValue = Pow(sources.Length);

            // inputs that can solve circuit
            var rightInputs = new List<bool[]>();
            for (int i = 0; i < maxValue; i++)
            {
                var inputs = GetBoolArrayFromInt(i, sources.Length);
                for (int j = 0; j < sources.Length; j++)
                {
                    sources[j].Value = inputs[j];
                }

                if (root.GetValue())
                {
                    rightInputs.Add(inputs);
                }
            }

            return rightInputs;
        }

        private static int Pow(int power)
        {
            int x = 2;
            for (int i = 1; i < power; i++)
            {
                x *= 2;
            }

            return x;
        }

        private static bool[] GetBoolArrayFromInt(int value, int length)
        {
            var bitArray = new BitArray(new[] {value});
            var boolArray = new bool[length];

            for (int i = length - 1; i >= 0; i—)
            {
                boolArray[i] = bitArray[i];
            }

            return boolArray;
        }

Решения находятся простым перебором. Для этого определяется максимальное число которое можно выразить набором бит в количестве равным количеству источников. То есть 4 источника = 4 бита = макс число 15. Перебираем все числа от 0 до 15.

ElementsProvider

Содержание

Для удобства генерации решил определить каждому элементу номер, Для этого создал класс ElementsProvider с интефрейсом IElementsProvider.

public interface IElementsProvider
{
   IList<Func<IElectricalElement>> Gates { get; }
   IList<Func<IElectricalElement>> Conductors { get; }
   IList<ElectricalElementType> GateTypes { get; }
   IList<ElectricalElementType> ConductorTypes { get; }
}
public class ElementsProvider : IElementsProvider
{
   public IList<Func<IElectricalElement>> Gates { get; } = new List<Func<IElectricalElement>>
   {
       () => new And(),
       () => new Nand(),
       () => new Or(),
       () => new Nor(),
       () => new Xor(),
       () => new Xnor()
   };

   public IList<Func<IElectricalElement>> Conductors { get; } = new List<Func<IElectricalElement>>
   {
       () => new Conductor(),
       () => new Not()
   };

   public IList<ElectricalElementType> GateTypes { get; } = new List<ElectricalElementType>
   {
       ElectricalElementType.And,
       ElectricalElementType.Nand,
       ElectricalElementType.Or,
       ElectricalElementType.Nor,
       ElectricalElementType.Xor,
       ElectricalElementType.Xnor
   };

   public IList<ElectricalElementType> ConductorTypes { get; } = new List<ElectricalElementType>
   {
       ElectricalElementType.Conductor,
       ElectricalElementType.Not
   };
}

Первые два списка представляют собой что то вроде фабрик, которые дают элемент по указанному номеру. Последние два списка это костыль, который приходится использовать из-за особенностей Unity. Об этом далее.

CircuitGenerator

Содержание

Теперь самая сложная часть разработки — генерация схем.

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

Задаются определенные параметры схемы, это: количество слоев (горизонтальных линий элементов) и максимальное количество элементов в слое. Также надо определять из каких вентилей нужно генерировать схемы.

Мой подход заключался в разбиении задачи на две части — генерация структуры и подбор вариантов.

Генератор структуры определяет позиции и соединения логических элементов.
Генератор вариантов подбирает валидные комбинации элементов на позициях.

StructureGenerator

Структура состоит из слоев логических элементов и слоев проводников/инверторов. Вся структура содержит не настоящие элементы а контейнеры для них.

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

ElectricalElementContainer : ElectricalElementBase, IElectricalElement

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

Make it True — Разработка логической игры на Unity - 5

Метод для установки списка элементов:

public void SetElements(IList<Func<IElectricalElement>> elements)
{
            Elements = new List<IElectricalElement>(elements.Count);
            foreach (var item in elements)
            {
                Elements.Add(item());
            }
 }

Далее можно установить тип таким образом:

public void SetType(int number)
{
            if (isInitialized == false)
            {
                throw new InvalidOperationException(UnitializedElementsExceptionMessage);
            }

            SelectedType = number;
            RealElement = Elements[number];
            ((ElectricalElementBase) RealElement).Input = Input;
}

После чего он будет работать как указанный элемент.

Была создана вот такая структура для схемы:

public class CircuitStructure : ICloneable
{
   public IDictionary<int, ElectricalElementContainer[]> Gates;
   public IDictionary<int, ElectricalElementContainer[]> Conductors;
   public Source[] Sources;
   public And FinalDevice;
}

Словари тут хранят номер слоя в ключе и массив контейнеров для этого слоя. Далее массив источников и один FinalDevice к которому все подключено.

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

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

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

Make it True — Разработка логической игры на Unity - 6

Но такие схемы очень скучные! Мы захотели упростить себе жизнь еще больше и решили сделать генерируемые структуры более интересными(сложными).Было принято решение добавить модификации структуры с ветвлением или соединение через множество слоев.

Ну сказать “упростили” — это значит усложнили себе жизнь в чем-то другом.
Генерация схем с максимальным уровнем модифицированности оказалось трудозатратным и не совсем практичным заданием. Поэтому наша команда решила сделать то, что соответствовало таким критериям:
Разработка этой задачи занимала не много времени.
Более-менее адекватная генерация модифицированных структур.
Не было пересечений между проводниками.
В итоге долгого и усердного программирования решение было написано за 4 вечера.
Давайте взглянем на код и̶ ̶у̶ж̶а̶с̶н̶ё̶м̶с̶я̶.

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

public IEnumerable<CircuitStructure> GenerateStructure(int lines, int maxElementsInLine, StructureModification modification)
{
            var baseStructure = GenerateStructure(lines, maxElementsInLine);

            for (int i = 0; i < lines; i++)
            {
                int maxValue = 1;
                int branchingSign = 1;
                if (modification == StructureModification.All)
                {
                    maxValue = 2;
                    branchingSign = 2;
                }

                int lengthOverflowArray = baseStructure.Gates[(i * 2) + 1].Length;
                var elementArray = new OverflowArray(lengthOverflowArray, maxValue);

                double numberOfOption = Math.Pow(2, lengthOverflowArray);
                for (int k = 1; k < numberOfOption - 1; k++)
                {
                    elementArray.Increase();
                    if (modification == StructureModification.Branching || modification == StructureModification.All)
                    {
                        if (!CheckOverflowArrayForAllConnection(elementArray, branchingSign, lengthOverflowArray))
                        {
                            continue;
                        }
                    }

                    // Clone CircuitStructure
                    var structure = (CircuitStructure) baseStructure.Clone();

                    ConfigureInputs(lines, structure.Conductors, structure.Gates);
                    var sources = AddSourcesLayer(structure.Conductors, maxElementsInLine);
                    var finalElement = AddFinalElement(structure.Conductors);
                    structure.Sources = sources;
                    structure.FinalDevice = finalElement;

                    int key = (i * 2) + 1;

                    ModifyStructure(structure, elementArray, key, modification);
                    ClearStructure(structure);
                    yield return structure;
                }
            }
}

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

Первое что мы делаем это создаем обыкновенную(базовую) структуру.

var baseStructure = GenerateStructure(lines, maxElementsInLine);

Потом, в результате несложной проверки, мы устанавливаем признак ветвления(branchingSign) в соответствующее значение.Зачем это надо? Дальше будет понятно.

int maxValue = 1;
int branchingSign = 1;
if (modification == StructureModification.All)
{
   maxValue = 2;
   branchingSign = 2;
}

Теперь мы определяем длину нашего OverflowArray и инициализируем его.

 int lengthOverflowArray = baseStructure.Gates[(i * 2) + 1].Length;
 var elementArray = new OverflowArray(lengthOverflowArray, maxValue);

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

int lengthOverflowArray = baseStructure.Gates[(i * 2) + 1].Length;

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

elementArray.Increase();

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

if (modification == StructureModification.Branching || modification == StructureModification.All)
{
                        if (!CheckOverflowArrayForAllConnection(elementArray, branchingSign, lengthOverflowArray))
                        {
                            continue;
                        }
}

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

// Clone CircuitStructure
var structure = (CircuitStructure) baseStructure.Clone();
ConfigureInputs(lines, structure.Conductors, structure.Gates);
var sources = AddSourcesLayer(structure.Conductors, maxElementsInLine);
var finalElement = AddFinalElement(structure.Conductors);
structure.Sources = sources;
structure.FinalDevice = finalElement;

И вот, наконец, мы приступаем к модификации структуры и ее чистке от ненужных элементов. Ненужные они стали в результате модификации структуры.

ModifyStructure(structure, elementArray, key, modification);
ClearStructure(structure);

Детальнее разбирать десятки мелких функций, которые выполняются “где-то там” в глубине не вижу смысла.

VariantsGenerator

Структуру + элементы которые должны находится в ней называю CircuitVariant.

public struct CircuitVariant
{
   public CircuitStructure Structure;
   public IDictionary<int, int[]> Gates;
   public IDictionary<int, int[]> Conductors;
   public IList<bool[]> Solutions;
}

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

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

То есть 6-ричное число из трех цифр будет представлять собой 3 элемента. Только стоит учесть, что может быть передано количество элементов не 6 а 4.

Для разряда такого числа, я определил структуру

public struct ClampedInt
{
        public int Value
        {
            get => value;
            set => this.value = Mathf.Clamp(value, 0, MaxValue);
        }
        
        public readonly int MaxValue;
        private int value;

        public ClampedInt(int maxValue)
        {
            MaxValue = maxValue;
            value = 0;
        }

        public bool TryIncrease()
        {
            if (Value + 1 <= MaxValue)
            {
                Value++;
                return false;
            }

            // overflow
            return true;
        }
}


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

В соответсвии с каждым ClampedInt устанавливаются значения соответствующих ElectricalElementContainer. Таким образом можно перебрать все возможные комбинации. Стоит обратить внимание, что в случае если требуется сгенерировать схему с элементами(например And (0) и Xor (4)) не нужно перебирать все варианты включая элементы 1,2,3. Для этог, во время генерации, элементы получают свои локальные номера(например And = 0, Xor = 1), а после они преобразуются обратно в глобальные номера.

Так можно перебирать все возможные комбинации во всех элементах.

После того как значения в контейнерах установлены, производится проверка схемы на наличие решений для нее, при помощи Solver. Если схема прошла решение — она возвращается.

После того как схема сгенерирована у нее проверяется количество решений. Оно не должно превышать лимит и не должно иметь решений состоящих полностью из 0 или 1.

Много кода

 public interface IVariantsGenerator
    {
        IEnumerable<CircuitVariant> Generate(IEnumerable<CircuitStructure> structures, ICollection<int> availableGates, bool useNot, int maxSolutions = int.MaxValue);
    }

    public class VariantsGenerator : IVariantsGenerator
    {
        private readonly ISolver solver;
        private readonly IElementsProvider elementsProvider;

        public VariantsGenerator(ISolver solver,
                                 IElementsProvider elementsProvider)
        {
            this.solver = solver;
            this.elementsProvider = elementsProvider;
        }

        public IEnumerable<CircuitVariant> Generate(IEnumerable<CircuitStructure> structures,
                                                    ICollection<int> availableGates,
                                                    bool useNot,
                                                    int maxSolutions = int.MaxValue)
        {
            bool manyGates = availableGates.Count > 1;
            var availableLeToGeneralNumber = GetDictionaryFromAllowedElements(elementsProvider.Gates, availableGates);
            var gatesList = GetElementsList(availableLeToGeneralNumber, elementsProvider.Gates);

            var availableConductorToGeneralNumber = useNot
                ? GetDictionaryFromAllowedElements(elementsProvider.Conductors, new[] {0, 1})
                : GetDictionaryFromAllowedElements(elementsProvider.Conductors, new[] {0});

            var conductorsList = GetElementsList(availableConductorToGeneralNumber, elementsProvider.Conductors);

            foreach (var structure in structures)
            {
                InitializeCircuitStructure(structure, gatesList, conductorsList);

                var gates = GetListFromLayersDictionary(structure.Gates);
                var conductors = GetListFromLayersDictionary(structure.Conductors);
                var gatesArray = new OverflowArray(gates.Count, availableGates.Count - 1);
                var conductorsArray = new OverflowArray(conductors.Count, useNot ? 1 : 0);

                do
                {
                    if (useNot && conductorsArray.EqualInts)
                    {
                        continue;
                    }

                    SetContainerValuesAccordingToArray(conductors, conductorsArray);
                    do
                    {
                        if (manyGates && gatesArray.Length > 1 && gatesArray.EqualInts)
                        {
                            continue;
                        }

                        SetContainerValuesAccordingToArray(gates, gatesArray);
                        var solutions = solver.GetSolutions(structure.FinalDevice, structure.Sources);
                        if (solutions.Any() && solutions.Count <= maxSolutions
                                            && !(solutions.Any(s => s.All(b => b)) || solutions.Any(s => s.All(b => !b))))
                        {
                            var variant = new CircuitVariant
                            {
                                Conductors = GetElementsNumberFromLayers(structure.Conductors, availableConductorToGeneralNumber),
                                Gates = GetElementsNumberFromLayers(structure.Gates, availableLeToGeneralNumber),
                                Solutions = solutions,
                                Structure = structure
                            };
                            yield return variant;
                        }
                    } while (!gatesArray.Increase());
                } while (useNot && !conductorsArray.Increase());
            }
        }

        private static void InitializeCircuitStructure(CircuitStructure structure, IList<Func<IElectricalElement>> gates, IList<Func<IElectricalElement>> conductors)
        {
            var lElements = GetListFromLayersDictionary(structure.Gates);
            foreach (var item in lElements)
            {
                item.SetElements(gates);
            }

            var cElements = GetListFromLayersDictionary(structure.Conductors);
            foreach (var item in cElements)
            {
                item.SetElements(conductors);
            }
        }

        private static IList<Func<IElectricalElement>> GetElementsList(IDictionary<int, int> availableToGeneralGate, IReadOnlyList<Func<IElectricalElement>> elements)
        {
            var list = new List<Func<IElectricalElement>>();
            foreach (var item in availableToGeneralGate)
            {
                list.Add(elements[item.Value]);
            }

            return list;
        }

        private static IDictionary<int, int> GetDictionaryFromAllowedElements(IReadOnlyCollection<Func<IElectricalElement>> allElements, IEnumerable<int> availableElements)
        {
            var enabledDic = new Dictionary<int, bool>(allElements.Count);
            for (int i = 0; i < allElements.Count; i++)
            {
                enabledDic.Add(i, false);
            }

            foreach (int item in availableElements)
            {
                enabledDic[item] = true;
            }

            var availableToGeneralNumber = new Dictionary<int, int>();
            int index = 0;
            foreach (var item in enabledDic)
            {
                if (item.Value)
                {
                    availableToGeneralNumber.Add(index, item.Key);
                    index++;
                }
            }

            return availableToGeneralNumber;
        }

        private static void SetContainerValuesAccordingToArray(IReadOnlyList<ElectricalElementContainer> containers, IOverflowArray overflowArray)
        {
            for (int i = 0; i < containers.Count; i++)
            {
                containers[i].SetType(overflowArray[i].Value);
            }
        }

        private static IReadOnlyList<ElectricalElementContainer> GetListFromLayersDictionary(IDictionary<int, ElectricalElementContainer[]> layers)
        {
            var elements = new List<ElectricalElementContainer>();
            foreach (var layer in layers)
            {
                elements.AddRange(layer.Value);
            }

            return elements;
        }

        private static IDictionary<int, int[]> GetElementsNumberFromLayers(IDictionary<int, ElectricalElementContainer[]> layers, IDictionary<int, int> elementIdToGlobal = null)
        {
            var dic = new Dictionary<int, int[]>(layers.Count);

            bool convert = elementIdToGlobal != null;

            foreach (var layer in layers)
            {
                var values = new int[layer.Value.Length];
                for (int i = 0; i < layer.Value.Length; i++)
                {
                    if (!convert)
                    {
                        values[i] = layer.Value[i].SelectedType;
                    }
                    else
                    {
                        values[i] = elementIdToGlobal[layer.Value[i].SelectedType];
                    }
                }

                dic.Add(layer.Key, values);
            }

            return dic;
        }
    }

Каждый из генераторов возвращает свой вариант при помощи оператора yield. Таким образом CircuitGenerator пользуясь StructureGenerator и VariantsGenerator генерирует IEnumerable.(подход с yield хорошо помог в будущем, см. далее)

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

Игровые классы

Подход к разработке и DI

Содержание

Проект строится под Dependency Injection (DI). Это означает, что классы могут просто требовать себе какой то объект соответствующий интерфейсу и не заниматься созданием этого объекта. Какие это дает преимущества:

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

Как DI контейнер в проекте используется Zenject.

Zenject имеет несколько контекстов, я использую только два из них:

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

Регистрация классов хранится в Installer-ах. Для контекста проекта я использую ScriptableObjectInstaller, а для контекста сцены — MonoInstaller.

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

После этого нужно как то создать MonoBehaviour классы, которые будут представлять эти элементы. Классы связанные с Unity я также выделил в отдельный проект зависимый от Core проекта.

Make it True — Разработка логической игры на Unity - 7

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

Для удобства DI часто создаю простой класс который выполняет всю логику, и MonoBehaviour обертку для него. Например, у класса есть Start и Update методы, создаю такие методы в классе, потом в MonoBehaviour классе добавляю поле-зависимость и в соответствующих методах вызываю Start и Update. Это дает “правильную” инжекцию в конструктор, отвязанность основного класса от DI контейнера и возможность легко тестировать.

Конфигурация

Содержание

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

  1. На каждую группу данных выделяется класс наследник ScriptableObject
  2. В нем создаются нужные сериализуемые поля
  3. Добавляются свойства на чтение из этих полей
  4. Выделяется интерфейс с вышеуказанными полями
  5. Класс регистрируется к интерфейсу в DI контейнере
  6. Profit

public interface ITags
{
        string FixedColor { get; }
        string BackgroundColor { get; }
        string ForegroundColor { get; }
        string AccentedColor { get; }
}
[CreateAssetMenu(fileName = nameof(Tags), menuName = "Configuration/" + nameof(Tags))]
public class Tags : ScriptableObject, ITags
{
        [SerializeField]
        private string fixedColor;

        [SerializeField]
        private string backgroundColor;

        [SerializeField]
        private string foregroundColor;

        [SerializeField]
        private string accentedColor;

        public string FixedColor => fixedColor;
        public string BackgroundColor => backgroundColor;
        public string ForegroundColor => foregroundColor;
        public string AccentedColor => accentedColor;

        private void OnEnable()
        {
            fixedColor.AssertNotEmpty(nameof(fixedColor));
            backgroundColor.AssertNotEmpty(nameof(backgroundColor));
            foregroundColor.AssertNotEmpty(nameof(foregroundColor));
            accentedColor.AssertNotEmpty(nameof(accentedColor));
        }
}

Для конфигурации отдельный инсталлер (код сокращён):

CreateAssetMenu(fileName = nameof(ConfigurationInstaller), menuName = "Installers/" + nameof(ConfigurationInstaller))]
    public class ConfigurationInstaller : ScriptableObjectInstaller<ConfigurationInstaller>
    {
        [SerializeField]
        private EditorElementsPrefabs editorElementsPrefabs;       

        [SerializeField]
        private LevelCompletionSteps levelCompletionSteps;

        [SerializeField]
        private CommonValues commonValues;

        [SerializeField]
        private AdsConfiguration adsConfiguration;

        [SerializeField]
        private CutscenesConfiguration cutscenesConfiguration;

        [SerializeField]
        private Colors colors;

        [SerializeField]
        private Tags tags;

        public override void InstallBindings()
        {
            Container.Bind<IEditorElementsPrefabs>().FromInstance(editorElementsPrefabs).AsSingle();           
            Container.Bind<ILevelCompletionSteps>().FromInstance(levelCompletionSteps).AsSingle();
            Container.Bind<ICommonValues>().FromInstance(commonValues).AsSingle();
            Container.Bind<IAdsConfiguration>().FromInstance(adsConfiguration).AsSingle();
            Container.Bind<ICutscenesConfiguration>().FromInstance(cutscenesConfiguration).AsSingle();
            Container.Bind<IColors>().FromInstance(colors).AsSingle();
            Container.Bind<ITags>().FromInstance(tags).AsSingle();
        }

        private void OnEnable()
        {
            editorElementsPrefabs.AssertNotNull();
            levelCompletionSteps.AssertNotNull();
            commonValues.AssertNotNull();
            adsConfiguration.AssertNotNull();
            cutscenesConfiguration.AssertNotNull();
            colors.AssertNOTNull();
            tags.AssertNotNull();
        }
}

Электрические элементы

Содержание

Теперь нужно как-то представить электрические элементы

public interface IElectricalElementMb
    {
        GameObject GameObject { get; }
        string Name { get; set; }
        IElectricalElement Element { get; set; }
        IOutputConnectorMb[] OutputConnectorsMb { get; }
        IInputConnectorMb[] InputConnectorsMb { get; }
        Transform Transform { get; }
        void SetInputConnectorsMb(InputConnectorMb[] inputConnectorsMb);
        void SetOutputConnectorsMb(OutputConnectorMb[] outputConnectorsMb);
    }

    [DisallowMultipleComponent]
    public class ElectricalElementMb : MonoBehaviour, IElectricalElementMb
    {
        [SerializeField]
        private OutputConnectorMb[] outputConnectorsMb;

        [SerializeField]
        private InputConnectorMb[] inputConnectorsMb;

        public Transform Transform => transform;

        public GameObject GameObject => gameObject;

        public string Name
        {
            get => name;
            set => name = value;
        }

        public virtual IElectricalElement Element { get; set; }

        public IOutputConnectorMb[] OutputConnectorsMb => outputConnectorsMb;

        public IInputConnectorMb[] InputConnectorsMb => inputConnectorsMb;
    }

    /// <summary>
    ///     Provide additional data to be able to configure it after manual install.
    /// </summary>
    public interface IElectricalElementMbEditor : IElectricalElementMb
    {
        ElectricalElementType Type { get; }
    }
    public class ElectricalElementMbEditor : ElectricalElementMb, IElectricalElementMbEditor
    {
        [SerializeField]
        private ElectricalElementType type;

        public ElectricalElementType Type => type;
    }

public interface IInputConnectorMb : IConnectorMb
    {
        IOutputConnectorMb OutputConnectorMb { get; set; }
        IInputConnector InputConnector { get; }
    }

    public class InputConnectorMb : MonoBehaviour, IInputConnectorMb
    {
        [SerializeField]
        private OutputConnectorMb outputConnectorMb;

        public Transform Transform => transform;

        public IOutputConnectorMb OutputConnectorMb
        {
            get => outputConnectorMb;
            set => outputConnectorMb = (OutputConnectorMb) value;
        }

        public IInputConnector InputConnector { get; } = new InputConnector();

        #if UNITY_EDITOR
        private void OnDrawGizmos()
        {
            if (outputConnectorMb != null)
            {
                Handles.DrawLine(transform.position, outputConnectorMb.Transform.position);
            }
        }
        #endif
    }

У нас есть строчка public IElectricalElement Element { get; set; }

Только вот как установить этот элемент?
Хорошим вариантом было бы сделать generic:
public class ElectricalElementMb: MonoBehaviour, IElectricalElementMb where T: IElectricalElement
Но вот загвоздка в том, что Unity не поддерживает generic в MonoBehaviour-классах. Более того, Unity не поддерживает сериализацию свойств и интерфейсов.

Тем не менее, в рантайме вполне можно передать в IElectricalElement Element { get; set; }
нужное значение.

Я сделал enum ElectricalElementType в котором будут все нужные типы. Enum хорошо сериализуется Unity и красиво отображается в Инспекторе в виде выпадающего списка. Определил два вида элемента: который создается в рантайме и который создается в редакторе и может быть сохранен. Таким образом, есть IElectricalElementMb и IElectricalElementMbEditor, который дополнительно содержит поле типа ElectricalElementType.

Второй тип так же необходимо инициализировать в рантайме. Для этого есть класс, который на старте обойдет все элементы и инициализирует их в зависимости от типа в enum поле. Следующим образом:

private static readonly Dictionary<ElectricalElementType, Func<IElectricalElement>> ElementByType =
            new Dictionary<ElectricalElementType, Func<IElectricalElement>>
            {
                {ElectricalElementType.And, () => new And()},
                {ElectricalElementType.Or, () => new Or()},
                {ElectricalElementType.Xor, () => new Xor()},
                {ElectricalElementType.Nand, () => new Nand()},
                {ElectricalElementType.Nor, () => new Nor()},
                {ElectricalElementType.NOT, () => new NOT()},
                {ElectricalElementType.Xnor, () => new Xnor()},
                {ElectricalElementType.Source, () => new Source()},
                {ElectricalElementType.Conductor, () => new Conductor()},
                {ElectricalElementType.Placeholder, () => new AlwaysFalse()},
                {ElectricalElementType.Encoder, () => new Encoder()},
                {ElectricalElementType.Decoder, () => new Decoder()}
            };

Game Management

Содержание

Далее возникает вопрос, где располагать логику самой игры(проверки условий прохождения, подсчет показаний прохождения и помощь игроку)?.. Так же есть вопросы расположения логики сохранения и загрузки прогресса, настроек и прочего.

Для этого я выделяю определенные классы-менеджеры, которые отвечают за некоторый класс задач.

DataManager отвечает за хранение данных результатов прохождения пользователя и настройки игры. Он зарегистрирован AsSingle в контексте проекта. Это значит, что он один на все приложение. Во время работы приложения данные хранятся прямо в памяти, внутри DataManager.
Он пользуется IFileStoreService, который отвечает за загрузку и сохранение данных и IFileSerializer отвечающий за сериализацию файлов в готовый вид для сохранения.

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

Существует в двух вариантах. Так и называются LevelGameManager1 и LevelGameManager2 для режима 1 и 2 соответственно.

В первом случае логика базируется на реакции на событие смены значения в одном из Источников и проверки значения на выходе схемы.

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

Есть некоторые данные текущего уровня такие, как номер уровня и помощь игроку.

Данные про текущий уровень хранятся в CurrentLevelData. Там хранится номер уровня — булевое свойство с проверкой на наличие помощи, флаг предложения оценить игру и данные для помощи игроку.

public interface ICurrentLevelData
{
        int LevelNumber { get; }
        bool HelpExist { get; }
        bool ProposeRate { get; }
}

public interface ICurrentLevelDataMode1 : ICurrentLevelData
{
        IEnumerable<SourcePositionValueHelp> PartialHelp { get; }
}

public interface ICurrentLevelDataMode2 : ICurrentLevelData
{
        IEnumerable<PlaceTypeHelp> PartialHelp { get; }
}

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

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

Отличия сцен разных режимов заключаются в том, что в контексте сцены устанавливается другой LevelGameManager и другой ICurrentLevelData.

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

Загрузка уровней

Содержание

Каждый уровень в игре представлен Unity-сценой, обязательно содержит префикс уровня и номер, например “Level23”. Префикс внесен в конфигурацию. Загрузка уровня происходит по названию, которое формируется из префикса. Таким образом класс LevelsManagerможет загружать уровни по номеру.

Катсцены

Содержание

Катсцены представляют собой обычные unity сцены с номерами в названии, аналогично уровням.
Сама анимация реализована при помощи Timeline. К сожалению ни навыков анимации, ни умения работать с Timeline у меня нет, так что “не стреляйте в пианиста — он играет, как умеет”.

Make it True — Разработка логической игры на Unity - 8

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

Make it True — Разработка логической игры на Unity - 9

Дополнительный геймплей

Содержание

Игра оценивается по количеству действий на уровне и использование подсказки. Чем меньше действий тем лучше. Использование подсказки снижает максимальную оценку до 2 звезд, пропуска уровня — до 1 звезды. Для оценки прохождения хранится количество шагов для прохождения. Оно состоит из двух значений: минимальное значение (на 3 звезды) и максимальное(1 звезду).

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

Make it True — Разработка логической игры на Unity - 10

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

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

Если игроку помощь не помогла, он может совсем пропустить уровень. В случае пропуска уровня игрок получает за него 1 звезду.

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

Монетизация

Содержание

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

Если игрок готов заплатить за отключение рекламы то он может это сделать. В этом случае не будет отображаться реклама между уровнями и при пропуске уровня.

Для рекламы создан класс под названием AdsService, с интерфейсом

public interface IAdsService
{
   bool AdsDisabled { get; }
   void LoadBetweenLevelAd();
   bool ShowBetweenLevelAd(int level, bool force = false);
   void LoadHelpAd(Action onLoaded = null);
   void ShowHelpAd(Action onRewarded, Action onClosed);
   bool HelpAdLoaded { get; }
}

Тут HelpAd — это вознаграждаемая реклама для пропуска уровня. Изначально мы называли помощь частичной и полной помощью. Частичная это подсказка, а полная это пропуск уровня.

Данный класс содержит внутри ограничение частоты показа рекламы по времени, после первого запуска игры.

В реализации используется Google Mobile Ads Unity Plugin.

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

Для покупок есть интерфейс

public interface IPurchaseService
{
   bool IsAdsDisablePurchased { get; }
   event Action DisableAdsPurchased;
   void BuyDisableAds();
   void RemoveDisableAd();
}

В реализации используется Unity IAP

С покупкой отключения рекламы есть хитрость. Google Play вроде как не предоставляет данные о том что игрок купил какую то покупку. Просто придет подтверждение, что она прошла один раз. Но если поставить продукту после покупки статус не Complete а Pending это позволит проверить свойство у продукта hasReceipt. Если оно true значит покупка была совершена.

Хотя конечно смущает такой подход.Подозреваю что может быть не все гладко.

Метод RemoveDisableAd нужен на время тестирования, он убирает купленное отключение рекламы.

Пользовательский интерфейс

Содержание

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

    public abstract class UiElementBase : MonoBehaviour, IUiElement
    {
        public event Action ShowClick;
        public event Action HideCLick;

        public void Show()
        {
            gameObject.SetActive(true);
            ShowClick?.Invoke();
        }

        public void Hide()
        {
            gameObject.SetActive(false);
            HideCLick?.Invoke();
        }
    }

public class PauseMenu : UiElementEscapeClose, IPauseMenu
    {
        [SerializeField]
        private Text levelNumberText;

        [SerializeField]
        private LocalizedText finishedText;

        [SerializeField]
        private GameObject restartButton;

        private int levelNumber;
        public event Action GoToMainMenuClick;
        public event Action RestartClick;

        public int LevelNumber
        {
            set => levelNumberText.text = $"{finishedText.Value} {value}";
        }

        public void DisableRestartButton()
        {
            restartButton.SetActive(false);
        }

        public void GoToMainMenu()
        {
            GoToMainMenuClick?.Invoke();
        }

        public void Restart()
        {
            RestartClick?.Invoke();
        }
    }

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

Аналитика

Содержание

По пути наименьшего сопротивления была выбрана аналитика от Unity. Простая в реализации, хотя и ограниченная для бесплатной подписки — невозможно экспортировать исходные данные. Также есть ограничение на количество событий — 100/час на игрока.
Для аналитики создал класс-обертку AnalyticsService. Он имеет методы для каждого типа события, получает необходимые параметры и вызывает отправку события средствами встроенными в Unity. Создавать метод на каждое событие конечно не лучшая практика в целом, но в заведомо маленьком проекте это лучше чем делать что-то большое и сложное.
Все используемые события это CustomEvent. Они строятся из названия события и словаря имя параметра и значение. AnalyticsService получает необходимыe значения из параметров и создает словарь внутри.

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

Пример метода:

public void LevelComplete(int number, int stars, int actionCount, TimeSpan timeSpent, int levelMode)
{
            CustomEvent(LevelCompleteEventName,
                        new Dictionary<string, object>
                        {
                            {LevelNumber, number},
                            {LevelStars, stars},
                            {LevelActionCount, actionCount},
                            {LevelTimeSpent, timeSpent},
                            {LevelMode, levelMode}
                        });
}

Позиционирование камеры и схемы

Содержание

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

Для этого создан класс CameraAlign. Алгоритм определения размера:

  1. Найти все нужные элементы на сцене
  2. Найти минимальную ширину и высоту с учетом соотношения сторон
  3. Определить размер камеры
  4. Установить камеру в центр
  5. Переместить FinalDevice к верхнему краю экрана
  6. Переместить источники к нижнему краю экрана

    public class CameraAlign : ICameraAlign
    {
        private readonly ISceneObjectsHelper sceneObjectsHelper;
        private readonly ICommonValues commonValues;

        public CameraAlign(ISceneObjectsHelper sceneObjectsHelper, ICommonValues commonValues)
        {
            this.sceneObjectsHelper = sceneObjectsHelper;
            this.commonValues = commonValues;
        }

        public void Align(Camera camera)
        {
            var elements = sceneObjectsHelper.FindObjectsOfType<IElectricalElementMb>();

            var finalDevice = sceneObjectsHelper.FindObjectOfType<IFinalDevice>();
            var sources = elements.OfType<ISourceMb>().ToArray();

            if (finalDevice != null && sources.Length > 0)
            {
                float leftPos = elements.Min(s => s.Transform.position.x);
                float rightPos = elements.Max(s => s.Transform.position.x);

                float width = Mathf.Abs(leftPos - rightPos);
                var fPos = finalDevice.Transform.position;

                float height = Mathf.Abs(sources.First().Transform.position.y - fPos.y) * camera.aspect;

                float size = Mathf.Max(width * commonValues.CameraOffset, height * commonValues.CameraOffset);

                camera.orthographicSize = Mathf.Clamp(size, commonValues.MinCameraSize, float.MaxValue);
                camera.transform.position = GetCenterPoint(elements, -1);

                fPos = new Vector2(fPos.x,
                                   camera.ScreenToWorldPoint(new Vector2(Screen.width, Screen.height)).y - commonValues.FinalDeviceTopOffset * camera.orthographicSize);
                finalDevice.Transform.position = fPos;
                float sourceY = camera.ScreenToWorldPoint(Vector2.zero).y + commonValues.SourcesBottomOffset;

                foreach (var item in sources)
                {
                    item.Transform.position = new Vector2(item.Transform.position.x, sourceY);
                }
            }
            else
            {
                Debug.Log($"{nameof(CameraAlign)}: No final device or no sources in scene");
            }
        }

        private static Vector3 GetCenterPoint(ICollection<IElectricalElementMb> elements, float z)
        {
            float top = elements.Max(e => e.Transform.position.y);
            float bottom = elements.Min(e => e.Transform.position.y);
            float left = elements.Min(e => e.Transform.position.x);
            float right = elements.Max(e => e.Transform.position.x);

            float x = left + ((right - left) / 2);
            float y = bottom + ((top - bottom) / 2);

            return new Vector3(x, y, z);
        }
    }

Этот метод вызывается при старте сцены в классе-обертке.

Цветовые схемы

Содержание

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

Для этого создал интерфейс

    public interface IColors
    {
        Color ColorAccent { get; }
        Color Background { get; set; }
        Color Foreground { get; set; }
        event Action ColorsChanged;
    }

Цвета можно установить прямо в редакторе Unity это можно использовать для тестирования. Далее их можно переключать и иметь два набора цветов.

Меняться могут цвета Background и Foreground, цветовой акцент один в любом режиме.

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

Далее есть несколько классов: CameraColorAdjustment — отвечает за установку цвета фона на камере, UiColorAdjustment — установка цветов элементов интерфейса и TextMeshColorAdjustment — устанавливает цвет цифр на источниках. UiColorAdjustment так же пользуется тегами. В редакторе можно отметить каждый элемент тегом, который будет означать какой тип цвета ему установить (Background, Foreground, AccentColor и FixedColor). Это все устанавливается на старте сцены или по событию изменения цветовой схемы.

Результат:

Make it True — Разработка логической игры на Unity - 11

Make it True — Разработка логической игры на Unity - 12

Расширения редактора

Содержание

Для упрощения и ускорения процесса разработки часто необходимо создать нужный инструмент, который не предоставляется стандартными средствами редактора. Традиционным подходом в Unity является создание класса-наследника EditorWindow. Так же есть подход с UiElements, но он еще в процессе разработки, потому решил воспользоваться традиционным подходом.

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

  • Выделить отдельный проект для скриптов редактора
  • Поместить файлы в папку Assets/Editor
  • Оборачивать эти файлы в #if UNITY_EDITOR

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

Все классы, которые нужны только в редакторе я определил в сборке, которая будет доступна только в редакторе. В билд игры она не пойдет.

Make it True — Разработка логической игры на Unity - 13

Хорошо бы теперь иметь DI в своих расширениях редактора. Для этого я использую Zenject.StaticContext. Для того чтобы его установить в редакторе используется класс с InitializeOnLoad атрибутом, в котором присутствует статический конструктор.

[InitializeOnLoad]
public class EditorInstaller
{
        static EditorInstaller()
        {
            var container = StaticContext.Container;
            container.Bind<IElementsProvider>().To<ElementsProvider>().AsSingle();
            container.Bind<ISolver>().To<Solver>().AsSingle();
            ....
         }
}

Для регистрации ScriptableObject-классов в статический контекст пользуюсь таким кодом:

BindFirstScriptableObject<ISceneNameConfiguration, SceneNameConfiguration>(container);

private static void BindFirstScriptableObject<TInterface, TImplementation>(DiContainer container)
where TImplementation : ScriptableObject, TInterface
{
            var obj = GetFirstScriptableObject<TImplementation>();
            container.Bind<TInterface>().FromInstance(obj).AsSingle();
}

private static T GetFirstScriptableObject<T>() where T : ScriptableObject
{
            var guids = AssetDatabase.FindAssets("t:" + typeof(T).Name);

            string path = AssetDatabase.GUIDToAssetPath(guids.First());
            var obj = AssetDatabase.LoadAssetAtPath<T>(path);

            return obj;
}

TImplementation требуется только для этой строчки AssetDatabase.LoadAssetAtPath(path)

В конструктор зависимость поместить не получится. Вместо этого в класс окна необходимо добавить атрибут [Inject] на полях-зависимостях и вызвать при старте окна
StaticContext.Container.Inject(this);

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

Generator

Содержание

Make it True — Разработка логической игры на Unity - 14
Первоначальный вид генератора

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

Окно состоит из трех разделов слева направо:

  • настройки генерации
  • список вариантов в виде кнопок
  • выбранный вариант в виде текста

Столбцы созданы при помощи EditorGUILayout.BeginVertical() и EditorGUILayout.EndVertical(). Зафиксировать и ограничить размеры к сожалению не получилось, но это не столь критично.

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

Тут я задумался, что вероятно весь код расширений редактора работает в Debug режиме. Под Release так хорошо дебаг не проходит, точки останова не останавливают, строки пропускаются и т.д. И действительно, померяв производительность оказалось, что скорость работы генератора в Unity соответствует Debug сборке запущенной из консольного приложения, а это в ~6 раз медленнее чем Release. Имейте это в виду.

Как вариант можно делать внешнюю сборку и добавлять в Unity DLL со сборкой, но это сильно усложняет сборку и редактирование проекта.

Сразу вынес процесс генерации в отдельный Task с кодом содержащим такое:
circuitGenerator.Generate(lines, maxElementsInLine, availableLogicalElements, useNOT, modification).ToList()

Уже лучше, редактор хоть не виснет на время генерации. Но все еще необходимо долго ждать, по несколько минут(более 20 минут на больших размерах схем). Плюс появилась проблема, что задачу так просто не завершить и она продолжает работать пока генерация не завершится.

Много кода

internal static class Ext
{
        public static IEnumerable<CircuitVariant> OrderVariants(this IEnumerable<CircuitVariant> circuitVariants)
        {
            return circuitVariants.OrderBy(a => a.Solutions.Count())
                                  .ThenByDescending(a => a.Solutions
                                                          .Select(b => b.Sum(i => i ? 1 : -1))
                                                          .OrderByDescending(b=>b)
                                                          .First());
        }
    }

    public interface IEditorGenerator : IDisposable
    {
        CircuitVariant[] FilteredVariants { get; }
        int LastPage { get; }

        void FilterVariants(int page);

        void Start(int lines,
                   int maxElementsInLine,
                   ICollection<int> availableGates,
                   bool useNOT,
                   StructureModification? modification,
                   int maxSolutions);

        void Stop();

        void Fetch();
    }

    public class EditorGenerator : IEditorGenerator
    {
        private const int PageSize = 100;

        private readonly ICircuitGenerator circuitGenerator;

        private ConcurrentBag<CircuitVariant> variants;

        private List<CircuitVariant> sortedVariants;

        private Thread generatingThread;

        public EditorGenerator(ICircuitGenerator circuitGenerator)
        {
            this.circuitGenerator = circuitGenerator;
        }

        public void Dispose()
        {
            generatingThread?.Abort();
        }

        public CircuitVariant[] FilteredVariants { get; private set; }

        public int LastPage { get; private set; }

        public void FilterVariants(int page)
        {
            CheckVariants();

            if (sortedVariants == null)
            {
                Fetch();
            }

            FilteredVariants = sortedVariants.Skip(page * PageSize)
                                             .Take(PageSize)
                                             .ToArray();
            int count = sortedVariants.Count;

            LastPage = count % PageSize == 0
                ? (count / PageSize) - 1
                : count / PageSize;
        }

        public void Fetch()
        {
            CheckVariants();

            sortedVariants = variants.OrderVariants()
                                     .ToList();
        }

        public void Start(int lines,
                          int maxElementsInLine,
                          ICollection<int> availableGates,
                          bool useNOT,
                          StructureModification? modification,
                          int maxSolutions)
        {
            if (generatingThread != null)
            {
                Stop();
            }

            variants = new ConcurrentBag<CircuitVariant>();
            generatingThread = new Thread(() =>
            {
                var v = circuitGenerator.Generate(lines,
                                                  maxElementsInLine,
                                                  availableGates,
                                                  useNOT,
                                                  modification,
                                                  maxSolutions);
                foreach (var item in v)
                {
                    variants.Add(item);
                }
            });
            generatingThread.Start();
        }

        public void Stop()
        {
            generatingThread?.Abort();
            sortedVariants = null;
            variants = null;
            generatingThread = null;
            FilteredVariants = null;
        }

        private void CheckVariants()
        {
            if (variants == null)
            {
                throw new InvalidOperationException("VariantsGeneration is not started. Use Start before.");
            }
        }

        ~EditorGenerator()
        {
            generatingThread.Abort();
        }
}

Идея в том, чтобы в фоне происходила генерация, а по запросу обновлялся внутренний список отсортированных вариантов. После чего можно постранично выбирать варианты. Таким образом нет необходимости каждый раз проводить сортировку, что заметно ускоряет работу на больших списках. Схемы сортируются по “интересности”: по количеству решений, по возрастанию и по тому насколько разнообразные значения требуются для решения. То есть схема с решением 1 1 1 1 менее интересная чем 1 0 1 1.

Make it True — Разработка логической игры на Unity - 15

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

Очень мешает особенность Unity в том, что при нажатии Play содержимое окна сбрасывается, как и все сгенерированные данные. Если бы они были легко сериализуемыми, то их можно было бы хранить в виде файлов. Таким образом можно даже сделать кэширование результатов генерации. Но увы сериализовать сложную структуру, где объекты ссылаются друг на друга, трудно.

В дополнение добавил в каждый вентиль строки вроде

if (Input.Length == 2)
{
            return Input[0].Value && Input[1].Value;
}

Что значительно улучшило производительность.

Solver

Содержание

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

Make it True — Разработка логической игры на Unity - 16

Логика работы его “backend”:

public string GetSourcesLabel()
{
            var sourcesMb = sceneObjectsHelper.FindObjectsOfType<SourceMb>().OrderBy(s => s.name);

            var sourcesLabelSb = new StringBuilder();
            foreach (var item in sourcesMb)
            {
                sourcesLabelSb.Append($"{item.name.Replace("Source", "Src")}t");
            }

            return sourcesLabelSb.ToString();
        }

        public IEnumerable<bool[]> FindSolutions()
        {
            var elementsMb = sceneObjectsHelper.FindObjectsOfType<IElectricalElementMbEditor>();
            elementsConfigurator.Configure(elementsMb);

            var root = sceneObjectsHelper.FindObjectOfType<FinalDevice>();
            if (root == null)
            {
                throw new InvalidOperationException("No final device in scene");
            }

            var sourcesMb = sceneObjectsHelper.FindObjectsOfType<SourceMb>().OrderBy(s => s.name);

            var sources = sourcesMb.Select(mb => (Source) mb.Element).ToArray();

            return solver.GetSolutions(root.Element, sources);
}

Полезное

Содержание

AssertHelper

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

public static class AssertHelper
{
        public static void AssertType(this IElectricalElementMbEditor elementMbEditor, ElectricalElementType expectedType)
        {
            if (elementMbEditor.Type != expectedType)
            {
                Debug.LogError($"Field for {expectedType} require element with such type, but given element is {elementMbEditor.Type}");
            }
        }

        public static void AssertNOTNull<T>(this T obj, string fieldName = "")
        {
            if (obj == null)
            {
                if (string.IsNullOrEmpty(fieldName))
                {
                    fieldName = $"of type {typeof(T).Name}";
                }

                Debug.LogError($"Field {fieldName} is not installed");
            }
        }

        public static string AssertNOTEmpty(this string str, string fieldName = "")
        {
            if (string.IsNullOrWhiteSpace(str))
            {
                Debug.LogError($"Field {fieldName} is not installed");
            }

            return str;
        }

        public static string AssertSceneCanBeLoaded(this string name)
        {
            if (!Application.CanStreamedLevelBeLoaded(name))
            {
                Debug.LogError($"Scene {name} can't be loaded.");
            }

            return name;
        }
}

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

Примеры использования:

mainMenuSceneName.AssertNOTEmpty(nameof(mainMenuSceneName)).AssertSceneCanBeLoaded();
levelNamePrefix.AssertNOTEmpty(nameof(levelNamePrefix));
editorElementsPrefabs.AssertNOTNull();
not.AssertType(ElectricalElementType.NOT); // в рамках костыля с enum для указания типа элемента

SceneObjectsHelper

Содержание

Для работы с элементами сцены, так же пригодился класс SceneObjectsHelper:

Много кода

namespace Circuit.Game.Utility
{
    public interface ISceneObjectsHelper
    {
        T[] FindObjectsOfType<T>(bool includeDisabled = false) where T : class;
        T FindObjectOfType<T>(bool includeDisabled = false) where T : class;
        T Instantiate<T>(T prefab) where T : Object;
        void DestroyObjectsOfType<T>(bool includeDisabled = false, bool immediate = false) where T : class;
        void Destroy<T>(T obj, bool immediate = false) where T : Object;
        void DestroyAllChildren(Transform transform);
        void Inject(object obj);
        T GetComponent<T>(GameObject obj) where T : class;
    }

    public class SceneObjectsHelper : ISceneObjectsHelper
    {
        private readonly DiContainer diContainer;

        public SceneObjectsHelper(DiContainer diContainer)
        {
            this.diContainer = diContainer;
        }

        public T GetComponent<T>(GameObject obj) where T : class
        {
            return obj.GetComponents<Component>().OfType<T>().FirstOrDefault();
        }

        public T[] FindObjectsOfType<T>(bool includeDisabled = false) where T : class
        {
            if (includeDisabled)
            {
                return Resources.FindObjectsOfTypeAll(typeof(Object)).OfType<T>().ToArray();
            }

            return Object.FindObjectsOfType<Component>().OfType<T>().ToArray();
        }

        public void DestroyObjectsOfType<T>(bool includeDisabled = false, bool immediate = false) where T : class
        {
            var objects = includeDisabled ? Resources.FindObjectsOfTypeAll(typeof(Object)).OfType<T>().ToArray() : Object.FindObjectsOfType<Component>().OfType<T>().ToArray();

            foreach (var item in objects)
            {
                if (immediate)
                {
                    Object.DestroyImmediate((item as Component)?.gameObject);
                }
                else
                {
                    Object.Destroy((item as Component)?.gameObject);
                }
            }
        }

        public void Destroy<T>(T obj, bool immediate = false) where T : Object
        {
            if (immediate)
            {
                Object.DestroyImmediate(obj);
            }
            else
            {
                Object.Destroy(obj);
            }
        }

        public void DestroyAllChildren(Transform transform)
        {
            int childCount = transform.childCount;
            for (int i = 0; i < childCount; i++)
            {
                Destroy(transform.GetChild(i).gameObject);
            }
        }

        public T FindObjectOfType<T>(bool includeDisabled = false) where T : class
        {
            if (includeDisabled)
            {
                return Resources.FindObjectsOfTypeAll(typeof(Object)).OfType<T>().FirstOrDefault();
            }

            return Object.FindObjectsOfType<Component>().OfType<T>().FirstOrDefault();
        }

        public void Inject(object obj)
        {
            diContainer.Inject(obj);
        }

        public T Instantiate<T>(T prefab) where T : Object
        {
            var obj = Object.Instantiate(prefab);

            if (obj is Component)
            {
                var components = ((Component) (object) obj).gameObject.GetComponents<Component>();
                foreach (var component in components)
                {
                    Inject(component);
                }
            }
            else
            {
                Inject(obj);
            }

            return obj;
        }
    }
}

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

CoroutineStarter

Содержание

Запустить Coroutine может только MonoBehaviour. Потому я создал класс CoroutineStarter и зарегистрировал его в контексте сцены.

public interface ICoroutineStarter
{
        void BeginCoroutine(IEnumerator routine);
}

public class CoroutineStarter : MonoBehaviour, ICoroutineStarter
{
        public void BeginCoroutine(IEnumerator routine)
        {
            StartCoroutine(routine);
        }
}

Помимо удобства, введение таких средств позволило облегчить автоматическое тестирование. Например выполнение корутины в тестах:

coroutineStarter.When(x => x.BeginCoroutine(Arg.Any<IEnumerator>())).Do(info =>
{
                var a = (IEnumerator) info[0];
                while (a.MoveNext()) { }
});

Gizmo

Содержание

Для удобства отображения невидимых элементов рекомендую использовать картинки-gizmo, которые видны только в сцене. Они позволяют легко выделить невидимый элемент кликом. Также сделал соединения элементов в виде линий:

private void OnDrawGizmos()
{
   if (outputConnectorMb != null)
   {
       Handles.DrawLine(transform.position, outputConnectorMb.Transform.position);
   }
}

Make it True — Разработка логической игры на Unity - 17

Тестирование

Содержание

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

Для Unit-тестов принято исопльзовать mock-объекты вместо классов имплементирующих интерфейс от которого зависит тестуремый класс. Для этого я воспользовался библиотекой NSubstitute. Чем очень доволен.

Unity не поддерживает NuGet, потому пришлось отдельно достать DLL, далее сборка, как зависимость добавляется к AssemblyDefinition файлу и без проблем используется.

Make it True — Разработка логической игры на Unity - 18

Для автоматического тестирования Unity предлагает TestRunner, который работает с очень популярным тест фреймворком NUnit. С точки зрения TestRunner тесты бывают двух видов:

  • EditMode — тесты выполняемые просто в редакторе, без старта сцены. Выглядят как обычные Nunit тесты. Выполняются без старта сцены, работают просто и быстро. В таком режиме так же можно тестировать GameObject и Monobehaviour классы. Если есть возможность, стоит отдавать предпочтение именно EditMode тестам.
  • PlayMode — тесты выполняются при запущенной сцене. Выполняются сильно медленнее

EditMode. По моему опыту, было много неудобств и странного поведения в этом режиме. Но тем не менее они удобны что бы автоматически проверить работоспособность приложения в целом. Так же предоставляют честную проверку для кода в таких методах как Start, Update и подобных.

PlayMode тесты могут быть описаны, как обычные NUnit тесты но есть альтернатива. В PlayMode может потребоваться подождать некоторое время или некоторое количество кадров. Для этого тесты должны быть описаны похожим на Coroutine способом. Возвращаемым значением должен быть IEnumerator/IEnumerable и внутри, для пропуска времени, необходимо использовать, например:

yield return null;

или

yield return new WaitForSeconds(1);

Есть и другие возвращаемые значения.

Такому тесту необходимо устанавливать атрибут UnityTest. Так же есть атрибуты
UnitySetUp и UnityTearDown с которыми необходимо использовать аналогичный подход.

Я, в свою очередь, разделяю EditMode тесты на Модульные и Интеграционные.

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

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

public class ElectricalElementTestsBase<TElement> where TElement : ElectricalElementBase, IElectricalElement, new()
{
        protected TElement element;

        protected IInputConnector mInput1;
        protected IInputConnector mInput2;
        protected IInputConnector mInput3;
        protected IInputConnector mInput4;

        [OneTimeSetUp]
        public void Setup()
        {
            element = new TElement();
            mInput1 = Substitute.For<IInputConnector>();
            mInput2 = Substitute.For<IInputConnector>();
            mInput3 = Substitute.For<IInputConnector>();
            mInput4 = Substitute.For<IInputConnector>();
        }

        protected void GetValue_3Input(bool input1, bool input2, bool input3, bool expectedOutput)
        {
            // arrange
            mInput1.Value.Returns(input1);
            mInput2.Value.Returns(input2);
            mInput3.Value.Returns(input3);

            element.Input = new[] {mInput1, mInput2, mInput3};

            // act
            bool result = element.GetValue();

            // assert
            Assert.AreEqual(expectedOutput, result);
        }
    
        protected void GetValue_2Input(bool input1, bool input2, bool expectedOutput)
        {
            // arrange
            mInput1.Value.Returns(input1);
            mInput2.Value.Returns(input2);

            element.Input = new[] {mInput1, mInput2};

            // act
            bool result = element.GetValue();

            // assert
            Assert.AreEqual(expectedOutput, result);
        }

        protected void GetValue_1Input(bool input, bool expectedOutput)
        {
            // arrange
            mInput1.Value.Returns(input);
            element.Input = new[] {mInput1};

            // act
            bool result = element.GetValue();

            // assert
            Assert.AreEqual(expectedOutput, result);
        }
}

Далее тесты элемента выглядят так:

public class AndTests : ElectricalElementTestsBase<And>
{
        [TestCase(false, false, false)]
        [TestCase(false, true, false)]
        [TestCase(true, false, false)]
        [TestCase(true, true, true)]
        public new void GetValue_2Input(bool input1, bool input2, bool output)
        {
            base.GetValue_2Input(input1, input2, output);
        }

        [TestCase(false, false)]
        [TestCase(true, true)]
        public new void GetValue_1Input(bool input, bool expectedOutput)
        {
            base.GetValue_1Input(input, expectedOutput);
        }
}

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

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

[Test]
public void FullHelpAgree_FinishesLevel()
{
            // arrange
            levelGameManager.Start();

            helpMenu.ClearReceivedCalls();
            dataManager.ClearReceivedCalls();

            // act
            helpMenu.FullHelpClick += Raise.Event<Action>();
            fullHelpWindow.Agreed += Raise.Event<Action<bool>>(true);

            // assert
            dataManager.Received().SaveGame();
            helpMenu.Received().Hide();
}

[Test]
public void ChangeSource_RootOutBecomeTrue_SavesGameOpensMenu()
{
            // arrange
            currentLevelData.IsTestLevel.Returns(false);
            rootOutputMb.OutputConnector.Value.Returns(true);

            // act
            levelGameManager.Start();
            levelFinishedMenu.ClearReceivedCalls();
            dataManager.ClearReceivedCalls();
            source.ValueChanged += Raise.Event<Action<bool>>(true);

            // assert
            dataManager.Received().SaveGame();
            levelFinishedMenu.Received().Show();
}

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

public class PlacerTests
{
        [Inject]
        private ICircuitEditorPlacer circuitEditorPlacer;

        [Inject]
        private ICircuitGenerator circuitGenerator;

        [Inject]
        private IEditorSolver solver;

        [Inject]
        private ISceneObjectsHelper sceneObjectsHelper;

        [TearDown]
        public void TearDown()
        {
            sceneObjectsHelper.DestroyObjectsOfType<IElectricalElementMb>(immediate: true);
        }

        [OneTimeSetUp]
        public void Setup()
        {
            var container = StaticContext.Container;
            container.Inject(this);
        }

        [TestCase(1, 2)]
        [TestCase(2, 2)]
        [TestCase(3, 4)]
        public void PlaceSolve_And_NoModifications_AllVariantsSolved(int lines, int elementsInLine)
        {
            var variants = circuitGenerator.Generate(lines, elementsInLine, new List<int> {0}, false);
            foreach (var variant in variants)
            {
                circuitEditorPlacer.PlaceCircuit(variant);
                var solutions = solver.FindSolutions();
                CollectionAssert.IsNOTEmpty(solutions);
            }
        }

        [TestCase(1, 2, StructureModification.Branching)]
        [TestCase(1, 2, StructureModification.ThroughLayer)]
        [TestCase(1, 2, StructureModification.All)]
        [TestCase(2, 2, StructureModification.Branching)]
        [TestCase(2, 2, StructureModification.ThroughLayer)]
        [TestCase(2, 2, StructureModification.All)]
        public void PlaceSolve_And_Modifications_AllVariantsSolved(int lines, int elementsInLine, StructureModification modification)
        {
            var variants = circuitGenerator.Generate(lines, elementsInLine, new List<int> {0}, false, modification);
            foreach (var variant in variants)
            {
                circuitEditorPlacer.PlaceCircuit(variant);
                var solutions = solver.FindSolutions();
                CollectionAssert.IsNOTEmpty(solutions);
            }
}

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

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

Для написания тестов удобно использовать средства оценки покрытия, но к сожалению работающих с Unity решений я не нашел.

Обнаружил проблему, что с обновлением Unity до 2018.3 тесты стали работать сильно медленнее, до 10 раз медленнее(на синтетическом примере). Проект содержит 288 EditMode тестов которые выполняются 11 секунд, хотя там ничего настолько долго не выполняется.

Итоги разработки

Содержание

Make it True — Разработка логической игры на Unity - 19
Скриншот игрового уровня

Логику некоторых игр можно сформулировать независимо от платформы. Это на раннем этапе дает легкость разработки и тестируемость автотестами.

DI это удобно. Даже с учетом того, что Unity нативно его не имеет, прикрученный сбоку весьма сносно работает.

Unity позволяет автоматически тестировать проект. Правда поскольку все встроенные компоненты GameObject не имеют интерфейсов и могут использоваться только непосредственно mock-ать такие вещи как Collider, SpriteRenderer, MeshRenderer и т.п. не выйдет. Хотя GetComponent позволяет получать компоненты по интерфейсу. Как вариант, писать для всего свои обертки.

Использование автотестов упростило процесс формирования начальной логики, пока не было никакого пользовательского интерфейса к коду. Тесты несколько раз находили ошибку сразу при разработке.Естественно, ошибки появлялись и далее, но зачастую на эту ошибку можно было написать дополнительные тесты / изменить существующие и в дальнейшем отлавливать ее автоматически. Ошибки с DI, префабами, scriptable objects и подобными, тестами отловить сложно, но возможно, поскольку можно использовать реальные инсталлеры для Zenject, которые подтянут зависимости, как это происходит в билде.

Unity генерирует огромное количество ошибок, крашится. Часто ошибки решаются перезапуском редактора. Сталкивался со странной потерей ссылок на объекты в префабах. Иногда префаб по ссылке становился уничтоженным (ToString() возвращает “null”) хотя все выглядит рабочим, префаб перетягивается на сцену и ссылка не пустая. Иногда теряются некоторые связи во всех сценах. Все вроде установлено, работало, но при переходе на другую ветку все сцены оказываются сломаны — нет ссылок между элементами.

К счастью, эти ошибки зачастую исправлялись перезапуском редактора или иногда удалением папки Library.

Всего от идеи до публикации в Google Play прошло примерно полгода. Сама разработка заняла месяца 3, в свободное от основной работы время.

Автор: ViacheslavRud

Источник

* - обязательные к заполнению поля