Хабрахабр

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

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

Рассчитано на разработчиков знакомых с 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: Игрок получает схему в которой может поменять элементы но не может поменять значения на входах.

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

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

Сюжет

Содержание

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

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

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

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

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

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

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

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

Разработка

Содержание

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

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

Core

Содержание

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

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

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

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

Содержание

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

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

Приступим.

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

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

public interface IConnector

}

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

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

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. Так же можно было применить композицию, но это потребует лишний код, который особого смысла не имеет.

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

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

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

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

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 к которому все подключено.

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

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

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

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

Поэтому наша команда решила сделать то, что соответствовало таким критериям:
Разработка этой задачи занимала не много времени.
Более-менее адекватная генерация модифицированных структур.
Не было пересечений между проводниками.
В итоге долгого и усердного программирования решение было написано за 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-ричного числа, можно перебрать все комбинации элементов. Всего логических элементов может быть 6 а проводников 2.
Можно представить себе систему счисления с основанием 6 и получить в каждом разряде цифры, которые соответствуют элементам.

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

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

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 = 1), а после они преобразуются обратно в глобальные номера. Стоит обратить внимание, что в случае если требуется сгенерировать схему с элементами(например And (0) и Xor (4)) не нужно перебирать все варианты включая элементы 1,2,3.

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

Если схема прошла решение — она возвращается. После того как значения в контейнерах установлены, производится проверка схемы на наличие решений для нее, при помощи 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.

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

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

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

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

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

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

Enum хорошо сериализуется Unity и красиво отображается в Инспекторе в виде выпадающего списка. Я сделал enum ElectricalElementType в котором будут все нужные типы. Таким образом, есть 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

Содержание

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

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

Он зарегистрирован AsSingle в контексте проекта. DataManager отвечает за хранение данных результатов прохождения пользователя и настройки игры. Во время работы приложения данные хранятся прямо в памяти, внутри 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может загружать уровни по номеру. Загрузка уровня происходит по названию, которое формируется из префикса.

Катсцены

Содержание

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

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

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

Содержание

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

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

Можно было конечно хранить словарь, но он во первых не сериализуется 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. Он имеет методы для каждого типа события, получает необходимые параметры и вызывает отправку события средствами встроенными в Unity. Также есть ограничение на количество событий — 100/час на игрока.
Для аналитики создал класс-обертку AnalyticsService. Они строятся из названия события и словаря имя параметра и значение. Создавать метод на каждое событие конечно не лучшая практика в целом, но в заведомо маленьком проекте это лучше чем делать что-то большое и сложное.
Все используемые события это 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, цветовой акцент один в любом режиме.

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

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

Результат:

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

Содержание

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

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

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

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

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

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

[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. В конструктор зависимость поместить не получится. Inject(this); Container.

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

Generator

Содержание


Первоначальный вид генератора

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

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

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

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

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

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

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

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

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

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

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

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

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

Solver

Содержание

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

Логика работы его “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

Содержание

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

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); }
}

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

Содержание

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

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

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

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

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

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

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

yield return null;

или

yield return new WaitForSeconds(1);

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

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

Я, в свою очередь, разделяю 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 решений я не нашел.

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

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

Содержание


Скриншот игрового уровня

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

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

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

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

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть