[Из песочницы] Make it True — Разработка логической игры на Unity
Хочу поделиться процессом разработки простой мобильной игры силами двух разработчиков и художника. Данная статья в большей мере состоит описания технической реализации.
Осторожно, много текста!
Статья не являются руководством или уроком, хотя надеюсь что читатели смогут вынести что то полезное из нее. Рассчитано на разработчиков знакомых с Unity имеющих некоторый опыт в программировании.
Содержание:
Идея
Геймплей
Сюжет
Разработка
Core
- Электрические элементы
- Solver
- ElementsProvider
- CircuitGenerator
Игровые классы
- Подход к разработке и DI
- Конфигурация
- Электрические элементы
- Game Management
- Загрузка уровней
- Катсцены
- Дополнительный геймплей
- Монетизация
- Пользовательский интерфейс
- Аналитика
- Позиционирование камеры и схемы
- Цветовые схемы
Расширения редактора
- Generator
- Solver
Полезное
- AssertHelper
- SceneObjectsHelper
- CoroutineStarter
- 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 файл.
Теперь Unity генерирует проект с соответствующим названием. Все что лежит в папке с .asmdef файлом будет относится к этому проекту и сборке.
Электрические элементы
Содержание
Стоит задача описать в коде взаимодействие логических элементов друг с другом.
- У элемента может быть множество входов и множество выходов
- Вход элемента должен подключаться к выходу другого элемента
- Сам элемент должен содержать свою логику
Приступим.
- Элемент содержит свою логику работы и ссылки на свои входы. При запросе значения с элемента он берет значения со входов, применяет к ним логику и возвращает полученный результат. Выходов может быть несколько, потому запрашивается значение для определенного выхода, по умолчанию 0.
- Чтобы брать значения на входе, будет входной коннектор, он хранит ссылку на другой — выходной коннектор.
- Выходной коннектор относится к конкретному элементу и хранит ссылку на свой элемент, при запросе значения он запрашивает его у элемента.
Стрелками указано направление данных, зависимость элементов в обратном направлении.
Определим интерфейс коннектора. С него можно получить значение.
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 GetSolutions(IElectricalElement root, params Source[] sources);
}
public class Solver : ISolver
{
public ICollection 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();
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> Gates { get; }
IList> Conductors { get; }
IList GateTypes { get; }
IList ConductorTypes { get; }
}
public class ElementsProvider : IElementsProvider
{
public IList> Gates { get; } = new List>
{
() => new And(),
() => new Nand(),
() => new Or(),
() => new Nor(),
() => new Xor(),
() => new Xnor()
};
public IList> Conductors { get; } = new List>
{
() => new Conductor(),
() => new Not()
};
public IList GateTypes { get; } = new List
{
ElectricalElementType.And,
ElectricalElementType.Nand,
ElectricalElementType.Or,
ElectricalElementType.Nor,
ElectricalElementType.Xor,
ElectricalElementType.Xnor
};
public IList ConductorTypes { get; } = new List
{
ElectricalElementType.Conductor,
ElectricalElementType.Not
};
}
Первые два списка представляют собой что то вроде фабрик, которые дают элемент по указанному номеру. Последние два списка это костыль, который приходится использовать из-за особенностей Unity. Об этом далее.
CircuitGenerator
Содержание
Теперь самая сложная часть разработки — генерация схем.
Стоит задача сгенерировать список схем, из которых в редакторе потом можно выбрать понравившуюся. Генерация нужна только для простых вентилей.
Задаются определенные параметры схемы, это: количество слоев (горизонтальных линий элементов) и максимальное количество элементов в слое. Также надо определять из каких вентилей нужно генерировать схемы.
Мой подход заключался в разбиении задачи на две части — генерация структуры и подбор вариантов.
Генератор структуры определяет позиции и соединения логических элементов.
Генератор вариантов подбирает валидные комбинации элементов на позициях.
StructureGenerator
Структура состоит из слоев логических элементов и слоев проводников/инверторов. Вся структура содержит не настоящие элементы, а контейнеры для них.
Контейнер представляет из себя класс унаследованный от IElectricalElement, который внутри содержит список допустимых элементов и может между ними переключаться. Каждый элемент имеет свой номер в списке.
ElectricalElementContainer : ElectricalElementBase, IElectricalElement
Контейнер может установить «себя» в один из элементов из списка. При инициализации необходимо передать ему список делегатов, которые создадут элементы. Внутри он вызывает каждый делегат и получает элемент. Далее можно установить конкретный тип этого элемента, это подключает внутренний элемент к тем же входам что и в контейнере и выход из контейнера будет браться из выхода этого элемента.
Метод для установки списка элементов:
public void SetElements(IList> elements)
{
Elements = new List(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 Gates;
public IDictionary Conductors;
public Source[] Sources;
public And FinalDevice;
}
Словари тут хранят номер слоя в ключе и массив контейнеров для этого слоя. Далее массив источников и один FinalDevice к которому все подключено.
Таким образом структурный генератор создает контейнеры и подключает их друг к другу. Создается это все по слоям, снизу вверх. Снизу самый широкий (больше всего элементов). Слой выше содержит элементов в два раза меньше и так пока не дойдем до минимума. Выходы всех элементов верхнего слоя подключаются к финальному устройству.
Слой логических элементов содержит контейнеры для вентилей. В слое проводников находятся элементы с одним входом и выходом. Элементы там могут быть либо проводником, либо элементом НЕТ. Проводник передает на выход то, что пришло на вход, а элемент НЕТ возвращает инвертированное значение на выходе.
Первыми создается массив источников. Генерация происходит снизу вверх, первым генерируется слой проводников, дальше слой логики, и на выходе из него опять проводники.
Но такие схемы очень скучные! Мы захотели упростить себе жизнь еще больше и решили сделать генерируемые структуры более интересными (сложными).Было принято решение добавить модификации структуры с ветвлением или соединение через множество слоев.
Ну сказать «упростили» — это значит усложнили себе жизнь в чем-то другом.
Генерация схем с максимальным уровнем модифицированности оказалось трудозатратным и не совсем практичным заданием. Поэтому наша команда решила сделать то, что соответствовало таким критериям:
Разработка этой задачи занимала не много времени.
Более-менее адекватная генерация модифицированных структур.
Не было пересечений между проводниками.
В итоге долгого и усердного программирования решение было написано за 4 вечера.
Давайте взглянем на код и̶ ̶у̶ж̶а̶с̶н̶ё̶м̶с̶я̶.
Тут встречается класс OverflowArray. По историческим причинам, он был добавлен после базовой структурной генерации и имеет больше отношение к генерации вариантов, потому располагается ниже.Ссылка.
public IEnumerable 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 Gates;
public IDictionary Conductors;
public IList 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 Generate(IEnumerable structures, ICollection 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 Generate(IEnumerable structures,
ICollection 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> gates, IList> 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> GetElementsList(IDictionary availableToGeneralGate, IReadOnlyList> elements)
{
var list = new List>();
foreach (var item in availableToGeneralGate)
{
list.Add(elements[item.Value]);
}
return list;
}
private static IDictionary GetDictionaryFromAllowedElements(IReadOnlyCollection> allElements, IEnumerable availableElements)
{
var enabledDic = new Dictionary(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 index = 0;
foreach (var item in enabledDic)
{
if (item.Value)
{
availableToGeneralNumber.Add(index, item.Key);
index++;
}
}
return availableToGeneralNumber;
}
private static void SetContainerValuesAccordingToArray(IReadOnlyList containers, IOverflowArray overflowArray)
{
for (int i = 0; i < containers.Count; i++)
{
containers[i].SetType(overflowArray[i].Value);
}
}
private static IReadOnlyList GetListFromLayersDictionary(IDictionary layers)
{
var elements = new List();
foreach (var layer in layers)
{
elements.AddRange(layer.Value);
}
return elements;
}
private static IDictionary GetElementsNumberFromLayers(IDictionary layers, IDictionary elementIdToGlobal = null)
{
var dic = new Dictionary(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 проекта.
Для MonoBehaviour классов я предпочитаю создавать свои интерфейсы. Это, помимо стандартных преимуществ интерфейсов, позволяет скрыть уж очень большое количество членов MonoBehaviour.
Для удобства DI часто создаю простой класс который выполняет всю логику, и MonoBehaviour обертку для него. Например, у класса есть Start и Update методы, создаю такие методы в классе, потом в MonoBehaviour классе добавляю поле-зависимость и в соответствующих методах вызываю Start и Update. Это дает «правильную» инжекцию в конструктор, отвязанность основного класса от DI контейнера и возможность легко тестировать.
Конфигурация
Содержание
Под конфигурацией я имею в виду общие для всего приложения данные. В моем случае это префабы, идентификаторы для рекламы и покупок, теги, названия сцен и т.п. Для этих целей я использую ScriptableObject«ы:
- На каждую группу данных выделяется класс наследник ScriptableObject
- В нем создаются нужные сериализуемые поля
- Добавляются свойства на чтение из этих полей
- Выделяется интерфейс с вышеуказанными полями
- Класс регистрируется к интерфейсу в DI контейнере
- 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
{
[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().FromInstance(editorElementsPrefabs).AsSingle();
Container.Bind().FromInstance(levelCompletionSteps).AsSingle();
Container.Bind().FromInstance(commonValues).AsSingle();
Container.Bind().FromInstance(adsConfiguration).AsSingle();
Container.Bind().FromInstance(cutscenesConfiguration).AsSingle();
Container.Bind().FromInstance(colors).AsSingle();
Container.Bind().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;
}
///
/// Provide additional data to be able to configure it after manual install.
///
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 InputC