Make it True - Developing a logic game on Unity



I want to share the development process of a simple mobile game by two developers and an artist. This article is largely a description of the technical implementation.
Caution, a lot of text!

The article is not a guide or lesson, although I hope that readers can learn something useful from it. Designed for developers familiar with Unity with some programming experience.

Content:


Idea
Gameplay
Story Core
Development

  1. Electrical elements
  2. Solver
  3. ElementsProvider
  4. CircuitGenerator

Game classes

  1. Development Approach and DI
  2. Configuration
  3. Electrical elements
  4. Game management
  5. Level loading
  6. Cutscenes
  7. Additional gameplay
  8. Monetization
  9. User interface
  10. Analytics
  11. Camera Positioning and Diagrams
  12. Color schemes

Editor Extensions


  1. Generator
  2. Solver

Useful

  1. Asserthelp
  2. SceneObjectsHelper
  3. Coroutinestarter
  4. Gizmo

Testing
Development Results

Idea


Content

An idea came up to make a simple mobile game in a short period.

Conditions:

  • Easy to implement game
  • Minimum Art Requirements
  • Short development time (several months)
  • With easy automation of content creation (levels, locations, game elements)
  • Quickly create a level if the game consists of a finite number of levels

In order to decide, but what actually do? After all, the idea came up to make a game, not the idea of ​​a game. It was decided to seek inspiration from the app store.

To the above items are added:

  • The game should have a certain popularity among the players (number of downloads + ratings)
  • The app store should not be crowded with similar games

A game was found with gameplay based on logical gates. There were no similar ones in large numbers. The game has many downloads and positive ratings. Nevertheless, having tried, there were some drawbacks that can be taken into account in your game.

The gameplay of the game is that the level is a digital circuit with many inputs and outputs. The player must choose a combination of inputs so that the output is logical 1. It does not sound very difficult. The game also has automatically generated levels, which suggests that the ability to automate the creation of levels, although it does not sound very simple. The game is also good for learning, which I really liked.

Pros:

  • Technical simplicity of gameplay
  • Looks easy to test with autotests
  • Ability to auto-generate levels

Minuses:

  • You must first create levels

Now explore the flaws of the game which inspired.

  • Not adapted to custom aspect ratio, like 18: 9
  • There is no way to skip a difficult level or get a hint
  • In the reviews there were complaints about a small number of levels
  • The reviews complained about the lack of variety of elements

We proceed to the planning of our game:

  • We use standard logic gates (AND, NAND, OR, NOR, XOR, XNOR, NOR, NOT)
  • Gates are displayed with a picture instead of a text designation, which is easier to distinguish. Since elements have standard ANSI notation, we use them.
  • We discard the switch that connects one input to one of the outputs. Due to the fact that it requires you to click on yourself and does not fit into the real digital elements a bit. Yes, and it's hard to imagine a toggle switch in a chip.
  • Add the elements of the encoder and decoder.
  • We introduce a mode in which the player must select the desired element in the cell with fixed values ​​at the inputs of the circuit.
  • We provide help to the player: hint + skipping level.
  • It would be nice to add some plot.

Gameplay


Contents

Mode 1: The player receives the circuit and has access to change the values ​​at the inputs.
Mode 2: The player receives a circuit in which he can change the elements but cannot change the values ​​at the inputs.

The gameplay will be in the form of pre-prepared levels. After completing the level, the player must get some result. This will be done in the form of the traditional three stars, depending on the result of the passage.

What can be the performance indicators:
Number of actions: Each interaction with the elements of the game increases the counter.
The number of differences in the resulting state from the original. Does not take into account how many attempts the player had to complete. Unfortunately, it does not fit with the second regime.
It would be nice to add the same mode with random level generation. But for now, put it off for later.

Plot


Content

While thinking about the gameplay and starting development, various ideas appeared to improve the game. And an interesting enough idea appeared - to add a plot.

It is about an engineer who designs circuits. Not bad, but not complete. Perhaps it is worth displaying the manufacture of chips based on what the player does? Somehow routine, there is no understandable and simple result.

Idea! An engineer develops a cool robot using his logic circuits. The robot is a fairly simple understandable thing and fits perfectly with the gameplay.

Remember the first paragraph, “Minimum requirements for art”? Something does not fit with the cutscenes in the plot. Then a familiar artist comes to the rescue, who agreed to help us.

Now let's decide on the format and integration of cutscenes into the game.

The plot must be displayed as cutscenes without scoring or a text description that will remove localization problems, simplify its understanding, and many play on mobile devices without sound. The game is a very real elements of digital circuits, that is, it is quite possible to connect this with reality.

Cutscenes and levels should be separate scenes. Before a certain level, a specific scene is loaded.

Well, the task is set, there are resources to fulfill, the work has begun to boil.

Development


Content

The platform was immediately identified, it is Unity. Yes a little overkill, but nonetheless I know her.

During development, the code is written immediately with tests or even after. But for a holistic narrative, testing is placed in a separate section below. The current section will describe the development process separately from testing.

Core


Content

The core of the gameplay looks pretty simple and not tied to the engine, so we started with the design in the form of C # code. It seems that you can select a separate core core logic. Take it out to a separate project.

Unity works with a C # solution and projects inside are a little unusual for a regular .Net developer, .sln and .csproj files are generated by Unity itself and changes inside these files are not accepted for consideration on the Unity side. He will simply overwrite them and delete all changes. To create a new project, you must use the Assembly Definition file.





Unity now generates a project with the appropriate name. Everything that lies in the folder with the .asmdef file will be related to this project and assembly.

Electrical elements


Content The

task is to describe in the code the interaction of logical elements with each other.

  • An element can have multiple inputs and multiple outputs.
  • The input of the element must be connected to the output of another element
  • The element itself must contain its own logic.

Let's get started.

  • The element contains its own logic of operation and links to its inputs. When requesting a value from an element, it takes values ​​from the inputs, applies logic to them, and returns the result. There can be several outputs, so the value for a specific output is requested, the default is 0.
  • To take the values ​​at the input, there will be an input connector p, it stores a link to another - the output connector.
  • The output connector refers to a specific element and stores a link to its element, when requesting a value, it requests it from the element.



The arrows indicate the direction of the data, the dependence of the elements in the opposite direction.
Define the interface of the connector. You can get the value from it.

public interface IConnector
{
    bool Value { get; }
}

Just how to connect it to another connector?

Define more interfaces.

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

IInputConnector is an input connector, it has a link to another connector.

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

The output connector refers to its element from which it will request a value.

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

The electrical element must contain a method that returns a value on a specific output, number is the number of the output.

I called it IElectricalElement, although it transmits only logical voltage levels, but on the other hand it can be an element that does not add logic at all, just conveys a value, like a conductor.

Now let's move on to the implementation

public class InputConnector : IInputConnector
{
        public IOutputConnector ConnectedOtherConnector { get; set; }
        public bool Value
        {
            get
            {                
                return ConnectedOtherConnector?.Value ?? false;
            }
        }
}

The incoming connector may not be connected, in which case it will return 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);
    }
}

The output should have a link to its element and its number in relation to the element.
Further, using this number, he requests a value from the element.

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

The base class for all elements, just contains an array of inputs.

Example implementation of an element:

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

The implementation is based entirely on logical operations without a hard truth table. Perhaps not as explicit as with the table, but it will be flexible, it will work on any number of inputs.
All logic gates have one output, so the value at the output will not depend on the input number.

Inverted elements are made as follows:

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

It is worth noting that here the GetValue method is overridden, and not overridden virtually. This is done based on the logic that if Nand saves to And, he will continue to behave like And. It was also possible to apply the composition, but this would require extra code, which does not make much sense.

In addition to ordinary valves, the following elements were created:
Source - a constant value source of 0 or 1.
Conductor - just the same conductor Or, only has a slightly different application, see generation.
AlwaysFalse - always returns 0, needed for the second mode.

Solver


Contents

Next, a class is useful for automatically finding combinations that yield 1 at the output of the circuit.

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

The solutions are brute force. For this, the maximum number is determined which can be expressed by a set of bits in an amount equal to the number of sources. That is, 4 sources = 4 bits = max number 15. We sort through all numbers from 0 to 15.

ElementsProvider


Contents

For convenience of generation, I decided to identify each element with a number. To do this, I created the ElementsProvider class with the IElementsProvider interface.

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

The first two lists are something like factories that give an item at the specified number. The last two lists are a crutch that has to be used due to the features of Unity. About it further.

CircuitGenerator


Content

Now the most difficult part of the development is the generation of circuits.

The task is to generate a list of schemes from which you can then select the one you like in the editor. Generation is needed only for simple valves.

Certain parameters of the scheme are set, these are: the number of layers (horizontal lines of elements) and the maximum number of elements in the layer. It is also necessary to determine from which gates you need to generate circuits.

My approach was to split the task into two parts - structure generation and selection of options.

The structure generator determines the positions and connections of logic elements.
The variant generator selects valid combinations of elements in positions.

Structuregener


The structure consists of layers of logic elements and layers of conductors / inverters. The whole structure does not contain real elements but containers for them.

The container is a class inherited from IElectricalElement, which inside contains a list of valid elements and can switch between them. Each item has its own number in the list.

ElectricalElementContainer : ElectricalElementBase, IElectricalElement


A container can set “itself” to one of the elements from the list. During initialization, you must give it a list of delegates who will create the items. Inside, it calls every delegate and gets the item. Then you can set the specific type of this element, this connects the internal element to the same inputs as in the container and the output from the container will be taken from the output of this element.



Method for setting the list of elements:

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

Next, you can set the type in this way:

public void SetType(int number)
{
            if (isInitialized == false)
            {
                throw new InvalidOperationException(UnitializedElementsExceptionMessage);
            }
            SelectedType = number;
            RealElement = Elements[number];
            ((ElectricalElementBase) RealElement).Input = Input;
}

After which it will work as the specified item.

The following structure was created for the circuit:

public class CircuitStructure : ICloneable
{
   public IDictionary Gates;
   public IDictionary Conductors;
   public Source[] Sources;
   public And FinalDevice;
}

Dictionaries here store the layer number in the key and an array of containers for this layer. Next is an array of sources and one FinalDevice to which everything is connected.

Thus the structural generator creates containers and connects them to each other. This is all created in layers, from bottom to top. The bottom is the widest (most of the elements). The layer above contains two times less elements and so on until we reach a minimum. The outputs of all elements of the top layer are connected to the final device.

The logic element layer contains containers for gates. In the layer of conductors there are elements with one input and output. Elements there can be either a conductor or an NO element. The conductor passes to the output what came to the input, and the NO element returns the inverted value at the output.

The first to create an array of sources. The generation occurs from the bottom up, the layer of conductors is generated first, then the layer of logic, and at the output from it again conductors.



But such schemes are very boring! We wanted to simplify our life even more and decided to make the generated structures more interesting (complex). It was decided to add structure modifications with branching or connection through many layers.

Well, to say “simplified” - this means complicating your life in something else.
Generating circuits with the maximum level of modifiability turned out to be a laborious and not quite practical task. Therefore, our team decided to do something that met the following criteria:
The development of this task did not take much time.
More or less adequate generation of modified structures.
There were no intersections between the conductors.
As a result of a long and hard programming, the solution was written in 4 pm.
Let's take a look at the code and ̶у̶ж̶а̶с̶н̶ё̶м̶с̶я̶.

Here the OverflowArray class is encountered. For historical reasons, it was added after the basic structural generation and has more to do with variant generation, therefore it is located below. Link .

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

After viewing this code, I would like to understand what is happening in it.
Do not worry! A brief explanation without details hurries to you.

The first thing we do is create an ordinary (base) structure.

var baseStructure = GenerateStructure(lines, maxElementsInLine);

Then, as a result of a simple check, we set the branching sign (branchingSign) to the appropriate value. Why is this necessary? Further it will be clear.

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

Now we determine the length of our OverflowArray and initialize it.

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

In order for us to continue our manipulations with the structure, we need to find out the number of possible variations of our OverflowArray. To do this, there is a formula that was applied on the next line.

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

Next is a nested loop in which all the “magic” takes place and for which there was all this preface. At the very beginning, we increase the values ​​of our array.

elementArray.Increase();

After that, we see a validation check, as a result of which we go further or the next iteration.

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

If the array passed the validation check, then we clone our base structure. Cloning is needed as we will modify our structure for many more iterations.

// 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;

And finally, we begin to modify the structure and clean it from unnecessary elements. They became unnecessary as a result of structural modification.

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

I do not see the point in more detail to analyze dozens of small functions that are performed “somewhere there” in the depths.

Variantsgenerator


The structure + elements that should be in it are called CircuitVariant.

public struct CircuitVariant
{
   public CircuitStructure Structure;
   public IDictionary Gates;
   public IDictionary Conductors;
   public IList Solutions;
}

The first field is a link to the structure. The second two dictionaries in which the key is the number of the layer, and the value is an array that contains the numbers of elements in their places in the structure.

We proceed to the selection of combinations. We can have a certain number of valid logic elements and conductors. In total, there can be 6 logical elements and 2 conductors.
You can imagine a number system with a base of 6 and get digits that correspond to the elements in each category. Thus, by increasing this hexadecimal number, you can go through all combinations of elements.

That is, a hexadecimal number of three digits will be 3 elements. Just take into account that the number of elements can be transferred not 6 but 4.

For the discharge of such a number, I determined the structure


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


Next is a class with the strange name OverflowArray . Its essence is that it stores the ClampedInt array and increases the high order in the event that an overflow occurs in the low order and so on until it reaches the maximum value in all cells.

In accordance with each ClampedInt, the values ​​of the corresponding ElectricalElementContainer are set. Thus, it is possible to sort through all possible combinations. It is worth noting that if you want to generate a scheme with elements (for example, And (0) and Xor (4)), you do not need to sort through all the options, including elements 1,2,3. For this, during generation, the elements get their local numbers (for example, And = 0, Xor = 1), and after that they are converted back to global numbers.

So you can iterate over all possible combinations in all elements.

After the values ​​in the containers are set, the circuit is checked for solutions for it using Solver . If the circuit passes the decision, it returns.

After the circuit is generated, the number of solutions is checked. It should not exceed the limit and should not have decisions consisting entirely of 0 or 1.

A lot of code
 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;
        }
    }


Each of the generators returns a variant using the yield statement. Thus, using the StructureGenerator and VariantsGenerator, the CircuitGenerator generates an IEnumerable. (The yield approach has helped in the future, see below).

Following from the fact that the variant generator receives a list of structures. You can generate options for each structure independently. This could be parallelized, but adding AsParallel did not work (probably yield interferes). Manually parallelize will be a long time, because we discard this option. In fact, I tried to do parallel generation, it worked, but there were some difficulties, because it did not go to the repository.

Game classes


Development Approach and DI


Content

The project is built under Dependency Injection (DI). This means that classes can simply require themselves some kind of object corresponding to the interface and not be involved in creating this object. What are the benefits:

  • The place of creation and initialization of the dependency object is defined in one place and separated from the logic of the dependent classes, which removes code duplication.
  • Eliminates the need to dig out the entire dependency tree and instantiate all the dependencies.
  • Allows you to easily change the implementation of the interface, which is used in many places.

As a DI container in the project, Zenject is used .

Zenject has several contexts, I use only two of them:

  • Project context - registration of dependencies within the entire application.
  • Контекст сцены: регистрация классов которые существуют только в конкретной сцене и их время жизни ограничено временем жизни сцены.
  • Статический контекст общий контекст для всего вообще, особенность в том что он существует в редакторе. Использую для инжекции в редакторе

Class registration is stored in Installer s. For context, I use project ScriptableObjectInstaller , and for the context of the scene - MonoInstaller .

Most of the classes I register with AsSingle, since they do not contain state, are more likely just containers for methods. I use AsTransient for classes where there is an internal state that should not be common to other classes.

After that, you need to somehow create MonoBehaviour classes that will represent these elements. I also allocated classes related to Unity to a separate project depending on the Core project.



For MonoBehaviour classes, I prefer to create my own interfaces. This, in addition to the standard advantages of interfaces, allows you to hide a very large number of MonoBehaviour members.

For convenience, DI often create a simple class that runs all the logic, and MonoBehaviour wrapper for it. For example, the class has Start and Update methods, I create such methods in the class, then in the MonoBehaviour class I add a dependency field and in the corresponding methods I call Start and Update. This gives the “correct” injection to the constructor, the detachment of the main class from the DI container and the ability to easily test.

Configuration


Content

By configuration, I mean data common to the entire application. In my case, these are prefabs, identifiers for advertising and purchases, tags, scene names, etc. For these purposes, I use ScriptableObjects:

  1. For each data group, a ScriptableObject descendant class is allocated.
  2. It creates the necessary serializable fields
  3. Read properties from these fields are added.
  4. The interface with the above fields is highlighted
  5. A class registers to an interface in a DI container
  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));
        }
}

For configuration, a separate installer (code abbreviated):

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

Electrical elements


Contents

Now you need to somehow imagine the electrical elements

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 InputConnector { get; } = new InputConnector();
        #if UNITY_EDITOR
        private void OnDrawGizmos()
        {
            if (outputConnectorMb != null)
            {
                Handles.DrawLine(transform.position, outputConnectorMb.Transform.position);
            }
        }
        #endif
    }

We have the line public IElectricalElement Element {get; set; }

Only here is how to set this element?
A good option would be to make generic:
public class ElectricalElementMb: MonoBehaviour, IElectricalElementMb where T: IElectricalElement
But the catch is that Unity does not support generic in MonoBehavior classes. Moreover, Unity does not support serialization of properties and interfaces.

Nevertheless, in runtime it is quite possible to pass in IElectricalElement Element {get; set; }
desired value.

I made enum ElectricalElementType in which there will be all necessary types. Enum is well serialized by Unity and nicely displayed in the Inspector as a drop-down list. Defined two types of element: which is created in runtime and which is created in the editor and can be saved. Thus, there is IElectricalElementMb and IElectricalElementMbEditor, which additionally contains a field of type ElectricalElementType.

The second type also needs to be initialized in runtime. To do this, there is a class that at the start will bypass all the elements and initialize them depending on the type in the enum field. In the following way:

private static readonly Dictionary> ElementByType =
            new Dictionary>
            {
                {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


Content

Next, the question arises, where to place the logic of the game itself (checking the conditions of passage, counting the readings of the passage and helping the player)? .. There are also questions about the location of the logic for saving and loading progress, settings and other things.

For this, I distinguish certain manager classes that are responsible for a certain class of tasks.

DataManager is responsible for storing data from the results of passing the user and game settings. It is registered by AsSingle in the context of the project. This means that he is one for the entire application. While the application is running, data is stored directly in memory, inside the DataManager.
He uses the IFileStoreService , which is responsible for loading and saving data and IFileSerializerresponsible for serializing files in a ready-made form for saving.

LevelGameManager is a game manager in a single scene.
I got a little GodObject, because he is still responsible for the UI, that is, opening and closing the menu, reaction to the buttons. But it is acceptable, given the size of the project and the lack of the need to expand it. So even easier and more clearly visible sequence of actions.

There are two options. This is what LevelGameManager1 and LevelGameManager2 are called for mode 1 and 2, respectively.

In the first case, the logic is based on the reaction to the event of a change in the value in one of the Sources and checking the value at the output of the circuit.

In the second case, the logic responds to an element change event and also checks the values ​​at the circuit output.

There is some current level information such as level number and player assistance.

Data about the current level is stored in CurrentLevelData . A level number is stored there - a Boolean property with a check for help, an offer flag to evaluate the game and data to help the player.

public interface ICurrentLevelData
{
        int LevelNumber { get; }
        bool HelpExist { get; }
        bool ProposeRate { get; }
}
public interface ICurrentLevelDataMode1 : ICurrentLevelData
{
        IEnumerable PartialHelp { get; }
}
public interface ICurrentLevelDataMode2 : ICurrentLevelData
{
        IEnumerable PartialHelp { get; }
}

Help for the first mode is the source numbers and values ​​on them. In the second mode, this is the type of element that needs to be set in the cell.

The collection contains structures that store the position and value that must be set at the specified position. A dictionary would be prettier, but Unity cannot serialize dictionaries.

The differences between the scenes of different modes are that in the context of the scene, another LevelGameManager and another ICurrentLevelData are set .

In general, I have an event-driven approach to communication of elements. On the one hand, it is logical and convenient. On the other hand, there is an opportunity to get problems without unsubscribing when necessary. Nevertheless, there were no problems in this project, and the scale is not too large. Usually, a subscription occurs during the start of the scene for everything you need. Almost nothing is created in runtime, so there is no confusion.

Level loading


Content

Each level in the game is represented by a Unity scene, it must contain a level prefix and a number, for example, “Level23”. The prefix is ​​included in the configuration. The loading of the level occurs by name, which is formed from the prefix. Thus, the LevelsManager class can load levels by number.

Cutscenes


The contents of the

cutscene are ordinary unity scenes with numbers in the title, similar to the levels.
The animation itself is implemented using Timeline. Unfortunately, I have neither animation skills nor the ability to work with Timeline, so “do not shoot the pianist - he plays as he can.”



The truth turned out that one logical cutscene should consist of different scenes with different objects. It turned out that this was noticed a little late, but it was decided simply: by placing parts of the cutscenes on the stage in different places and instantly moving the camera.



Additional gameplay


Content

The game is evaluated by the number of actions per level and the use of clues. The less action the better. Using the tooltip reduces the maximum rating to 2 stars, skipping the level to 1 star. To assess the passage, the number of steps for passing is stored. It consists of two values: the minimum value (for 3 stars) and the maximum (1 star).

The number of steps for passing levels is not stored in the scene file itself, but in the configuration file, since you need to display the number of stars for the level passed. This slightly complicated the process of creating levels. It was especially interesting to see changes in the version control system:



Try to guess what level it belongs to. It was possible to store the dictionary, of course, but in the first place it was not serialized by Unity, in the second one would have to manually set the numbers.

If it is difficult for the player to complete the level, he can get a hint - the correct values ​​on some inputs, or the correct element in the second mode. This was also done manually, although it could be automated.

If the player’s help did not help, he can completely skip the level. In case of missing a level, the player gets 1 star for him.

A user who has passed a level with a hint cannot re-run it for a while, so that it would be difficult to re-run the level with fresh memory, as if without a hint.

Monetization


Content

There are two types of monetization in the game: displaying ads and disabling ads for money. An ad display includes displaying ads between levels and viewing rewarded ads to skip a level.

If the player is willing to pay for disabling advertising, then he can do it. In this case, ads between levels and when skipping a level will not be displayed.

For advertising, a class called AdsService was created , with an interface

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

Here HelpAd is a rewarded ad for skipping a level. Initially, we called help partial and full help. Partial is a hint, and full is a skip level.

This class contains inside the limitation of the frequency of showing ads by time, after the first launch of the game.

The implementation uses Google Mobile Ads Unity Plugin .

With rewarded advertising, I stepped on a rake - it turns out that loyal delegates can be called in another thread, it is not very clear why. Therefore, it is better that those delegates do not call anything in code related to Unity. If a purchase was made to disable advertising, the advertisement will not be displayed and the delegate will immediately execute the successful display of the advertisement.

There is an interface for shopping

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

Unity IAP is used in the implementation

. There is a trick to buying ad disconnections. Google Play does not seem to provide information that the player bought a purchase. Just confirmation will come that she passed once. But if you put the status of the product after the purchase not Complete but Pending, this will allow you to check the property of the hasReceipt product . If it is true, the purchase has been completed.

Although of course it confuses such an approach. I suspect that it may not be all smooth.

The RemoveDisableAd method is needed at the time of testing, it removes the purchased advertising outage.

User interface


Content

All interface elements work in accordance with an event-oriented approach. Interface elements themselves usually do not contain logic other than events called by public methods that Unity can use. Although it also happens to perform some duties related only to the interface.

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

In fact, this is not always the case. It’s good to leave these elements as active View, make an event listener from it, something like a controller that will trigger the necessary actions on managers.

Analytics


Content

On the path of least resistance, Unity analytics was chosen . Easy to implement, although limited for a free subscription - it is impossible to export the source data. There is also a limit on the number of events - 100 / hour per player.
For analytics, created the wrapper class AnalyticsService . It has methods for each type of event, receives the necessary parameters and causes the event to be sent using the tools built into Unity. Creating a method for each event is certainly not the best practice as a whole, but in a knowingly small project it is better than doing something big and complicated.
All events used are CustomEvent.. They are built from the name of the event and the dictionary parameter name and value. AnalyticsService gets the required values ​​from the parameters and creates a dictionary inside.

All event names and parameters are placed in constants. Not in the form of a traditional approach with ScriptableObject, as these values ​​should never change.

Method Example:

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

Camera Positioning and Diagrams


Contents The

task is to place FinalDevice on top of the screen, at the same distance from the upper border and Sources from the bottom also always at an equal distance from the lower border. In addition, the screens come in different aspect ratios, you need to adjust the size of the camera before starting the level so that it fits the circuit correctly.

To do this, the CameraAlign class is created . Size Algorithm:

  1. Find all the necessary elements on the stage
  2. Find the minimum width and height based on aspect ratio
  3. Determine camera size
  4. Set the camera in the center
  5. Move FinalDevice to the top of the screen
  6. Move sources to the bottom of the screen

    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();
            var finalDevice = sceneObjectsHelper.FindObjectOfType();
            var sources = elements.OfType().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 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);
        }
    }

This method is called when the scene starts in the wrapper class.

Color schemes


Content

Since the game will have a very primitive interface, I decided to make it with two color schemes, black and white.

To do this, created an interface

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

Colors can be set directly in the Unity editor; this can be used for testing. Then they can be switched and have two sets of colors.

Background and Foreground colors can change, one color accent in any mode.

Since the player can set a non-standard theme, the color data must be stored in the settings file. If the settings file did not contain color data, then they are filled with standard values.

Then there are several classes: CameraColorAdjustment - responsible for setting the background color on the camera, UiColorAdjustment - setting the colors of interface elements and TextMeshColorAdjustment- sets the color of the numbers on the sources. UiColorAdjustment also uses tags. In the editor, you can mark each element with a tag that will indicate what type of color it should be set for (Background, Foreground, AccentColor and FixedColor). This is all set at the start of the scene or by the event of a color scheme change.

Result:





Editor Extensions


Contents

To simplify and speed up the development process, it is often necessary to create the right tool, which is not provided by standard editor tools. The traditional approach in Unity is to create an EditorWindow descendant class. There is also an approach with UiElements, but it is still under development, so I decided to use the traditional approach.

If you simply create a class that uses something from the UnityEditor namespace next to other classes for the game, then the project simply will not be assembled, since this namespace is not available in the build. There are several solutions:

  • Select a separate project for editor scripts
  • Place files in the Assets / Editor folder
  • Wrap these files in #if UNITY_EDITOR

The project uses the first approach and sometimes #if UNITY_EDITOR, if necessary, add a small part for the editor to the class that is required in the build.

All classes that are needed only in the editor I defined in the assembly, which will be available only in the editor. She will not go to the build of the game.



It would be nice now to have DI in your editor extensions. For this I use Zenject.StaticContext. In order to set it in the editor, a class with the InitializeOnLoad attribute is used, in which there is a static constructor.

[InitializeOnLoad]
public class EditorInstaller
{
        static EditorInstaller()
        {
            var container = StaticContext.Container;
            container.Bind().To().AsSingle();
            container.Bind().To().AsSingle();
            ....
         }
}

To register ScriptableObject classes in a static context, I use the following code:

BindFirstScriptableObject(container);
private static void BindFirstScriptableObject(DiContainer container)
where TImplementation : ScriptableObject, TInterface
{
            var obj = GetFirstScriptableObject();
            container.Bind().FromInstance(obj).AsSingle();
}
private static T GetFirstScriptableObject() where T : ScriptableObject
{
            var guids = AssetDatabase.FindAssets("t:" + typeof(T).Name);
            string path = AssetDatabase.GUIDToAssetPath(guids.First());
            var obj = AssetDatabase.LoadAssetAtPath(path);
            return obj;
}

TImplementation is required only for this line. AssetDatabase.LoadAssetAtPath (path)

It is not possible to add a dependency to the constructor. Instead, add the [Inject] attribute to the dependency fields in the window class and call
StaticContext.Container.Inject (this) at window startup;

I also recommend adding to the window update cycle a null check of one of the dependent fields, and if the field is empty, perform the above line. Because after changing the code in the project, Unity can recreate the window and not call Awake on it.

Generator


Contents The


initial view of the generator.

The window should provide an interface to generate a list of schemes with parameters, display a list of schemes and place the selected scheme on the current scene.

The window consists of three sections from left to right:

  • generation settings
  • list of options in the form of buttons
  • selected option as text

Columns are created using EditorGUILayout.BeginVertical () and EditorGUILayout.EndVertical (). Unfortunately, it did not work to fix and limit the sizes, but this is not so critical.

It turned out that the generation process on a large number of circuits is not so fast. A lot of combinations are obtained with the elements of I. As the profiler showed, the slowest part is the circuit itself. Parallelizing it is not an option; all options use one scheme, but it is difficult to clone this structure.

Then I thought that probably all the code of the editor extensions works in Debug mode. Under Release, the debug does not work so well, breakpoints do not stop, lines are skipped, etc. Indeed, having measured the performance, it turned out that the speed of the generator in Unity corresponds to the Debug assembly launched from the console application, which is ~ 6 times slower than Release. Keep this in mind.

Alternatively, you can do external assembly and add to the Unity DLL with the assembly, but this greatly complicates the assembly and editing of the project.

Immediately brought the generation process into a separate Task with code containing this:
circuitGenerator.Generate (lines, maxElementsInLine, availableLogicalElements, useNOT, modification) .ToList ()

Already better, the editor does not even hang at the time of generation. But it is still necessary to wait a long time, for several minutes (more than 20 minutes on large-sized circuits). Plus, there was a problem that the task cannot be completed so easily and it continues to work until the generation is completed.

A lot of code
internal static class Ext
{
        public static IEnumerable OrderVariants(this IEnumerable 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 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 variants;
        private List 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 availableGates,
                          bool useNOT,
                          StructureModification? modification,
                          int maxSolutions)
        {
            if (generatingThread != null)
            {
                Stop();
            }
            variants = new ConcurrentBag();
            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();
        }
}


The idea is that the background should be generated, and upon request, the internal list of sorted options will be updated. Then you can page by page to select options. Thus, there is no need to sort each time, which significantly speeds up work on large lists. Schemes are sorted by “interestingness”: by the number of solutions, by increase, and by how various values ​​are required for the solution. That is, a circuit with a solution of 1 1 1 1 is less interesting than 1 0 1 1.



Thus, it turned out, without waiting for the end of generation, to already select a circuit for the level. Another plus is that due to pagination, the editor does not slow down like cattle.

The Unity feature is very disturbing in that when you click Play, the contents of the window are reset, like all generated data. If they were easily serializable, they could be stored as files. In this way, you can even cache the results of the generation. But alas, serializing a complex structure where objects refer to each other is difficult.

In addition, I added lines to each gate, like

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

Which greatly improved performance.

Solver


Contents

When you assemble a circuit in the editor, you need to be able to quickly understand whether it is being solved and how many solutions it has. To do this, I created a “solver” window. It provides solutions to the current scheme in the form of a text.



The logic of its “backend”:

public string GetSourcesLabel()
{
            var sourcesMb = sceneObjectsHelper.FindObjectsOfType().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 FindSolutions()
        {
            var elementsMb = sceneObjectsHelper.FindObjectsOfType();
            elementsConfigurator.Configure(elementsMb);
            var root = sceneObjectsHelper.FindObjectOfType();
            if (root == null)
            {
                throw new InvalidOperationException("No final device in scene");
            }
            var sourcesMb = sceneObjectsHelper.FindObjectsOfType().OrderBy(s => s.name);
            var sources = sourcesMb.Select(mb => (Source) mb.Element).ToArray();
            return solver.GetSolutions(root.Element, sources);
}

Useful


Content

Asserthelp


Contents
To verify that the values ​​are set in assets, I use extension methods that I call in 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(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;
        }
}

Verifying that the scene has the ability to be loaded may sometimes fail, although the scene may be loaded. Perhaps this is a bug in Unity.

Examples of using:

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

SceneObjectsHelper


Contents

To work with scene elements, the SceneObjectsHelper class was also useful:

A lot of code
namespace Circuit.Game.Utility
{
    public interface ISceneObjectsHelper
    {
        T[] FindObjectsOfType(bool includeDisabled = false) where T : class;
        T FindObjectOfType(bool includeDisabled = false) where T : class;
        T Instantiate(T prefab) where T : Object;
        void DestroyObjectsOfType(bool includeDisabled = false, bool immediate = false) where T : class;
        void Destroy(T obj, bool immediate = false) where T : Object;
        void DestroyAllChildren(Transform transform);
        void Inject(object obj);
        T GetComponent(GameObject obj) where T : class;
    }
    public class SceneObjectsHelper : ISceneObjectsHelper
    {
        private readonly DiContainer diContainer;
        public SceneObjectsHelper(DiContainer diContainer)
        {
            this.diContainer = diContainer;
        }
        public T GetComponent(GameObject obj) where T : class
        {
            return obj.GetComponents().OfType().FirstOrDefault();
        }
        public T[] FindObjectsOfType(bool includeDisabled = false) where T : class
        {
            if (includeDisabled)
            {
                return Resources.FindObjectsOfTypeAll(typeof(Object)).OfType().ToArray();
            }
            return Object.FindObjectsOfType().OfType().ToArray();
        }
        public void DestroyObjectsOfType(bool includeDisabled = false, bool immediate = false) where T : class
        {
            var objects = includeDisabled ? Resources.FindObjectsOfTypeAll(typeof(Object)).OfType().ToArray() : Object.FindObjectsOfType().OfType().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 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(bool includeDisabled = false) where T : class
        {
            if (includeDisabled)
            {
                return Resources.FindObjectsOfTypeAll(typeof(Object)).OfType().FirstOrDefault();
            }
            return Object.FindObjectsOfType().OfType().FirstOrDefault();
        }
        public void Inject(object obj)
        {
            diContainer.Inject(obj);
        }
        public T Instantiate(T prefab) where T : Object
        {
            var obj = Object.Instantiate(prefab);
            if (obj is Component)
            {
                var components = ((Component) (object) obj).gameObject.GetComponents();
                foreach (var component in components)
                {
                    Inject(component);
                }
            }
            else
            {
                Inject(obj);
            }
            return obj;
        }
    }
}


Here, some things may not be very effective where high performance is needed, but they are rarely called for me and do not create any influence. But they allow you to find objects by the interface, for example, which looks pretty pretty.

Coroutinestarter


Contents

Launch Coroutine can only MonoBehaviour. So I created the CoroutineStarter class and registered it in the context of the scene.

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

In addition to convenience, the introduction of such tools made it easier to automatically test. For example, the execution of coroutine in tests:

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

Gizmo


Contents

For the convenience of displaying invisible elements, I recommend using gizmo pictures that are visible only in the scene. They make it easy to select an invisible element with a click. Also made connections of elements in the form of lines:

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



Testing


Content

I wanted to get the most out of automatic testing, because tests were used wherever possible and easy to use.

For Unit tests, it is customary to use mock objects instead of the classes implementing the interface on which the test class depends. For this, I used the NSubstitute library . What is very pleased.

Unity does not support NuGet, so I had to get the DLL separately, then the assembly, as a dependency is added to the AssemblyDefinition file and is used without problems.



For automatic testing, Unity offers TestRunner, which works with the very popular NUnit test framework . From the point of view of TestRunner, there are two types of tests:

  • EditMode — тесты выполняемые просто в редакторе, без старта сцены. Выглядят как обычные Nunit тесты. Выполняются без старта сцены, работают просто и быстро. В таком режиме так же можно тестировать GameObject и Monobehaviour классы. Если есть возможность, стоит отдавать предпочтение именно EditMode тестам.
  • PlayMode — тесты выполняются при запущенной сцене. Выполняются сильно медленнее
EditMode In my experience, there have been many inconveniences and strange behavior in this mode. Nevertheless, they are convenient to automatically check the health of the application as a whole. They also provide honest verification for code in methods such as Start, Update, and the like.

PlayMode tests can be described as normal NUnit tests, but there is an alternative. In PlayMode, you may need to wait a while or a certain number of frames. To do this, tests must be described in a manner similar to Coroutine. The returned value should be IEnumerator / IEnumerable and inside, to skip time, you must use, for example:

yield return null;

or

yield return new WaitForSeconds(1);

There are other return values.

Such a test needs to set the UnityTest attribute . There are also
UnitySetUp and UnityTearDown attributes with which you need to use a similar approach.

I, in turn, share EditMode tests for Modular and Integration.

Unit tests test only one class in complete isolation from other classes. Such tests often make it easier to prepare the environment for the tested class and errors, when passed, allow you to more accurately localize the problem.

In unit tests, I test many Core classes and classes needed directly in the game.
The circuit element tests are very similar, so I created a base class

public class ElectricalElementTestsBase 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();
            mInput2 = Substitute.For();
            mInput3 = Substitute.For();
            mInput4 = Substitute.For();
        }
        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);
        }
}

Further element tests look like this:

public class AndTests : ElectricalElementTestsBase
{
        [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);
        }
}

Perhaps this is a complication in terms of ease of understanding, which is usually not necessary in tests, but I did not want to copy-paste the same thing 11 times.

There are also tests of GameManagers. Since they have a lot in common, they also got a base class of tests. Game managers in both modes should have some identical functionality and some different. General things are tested with the same tests for each successor and specific behavior is tested in addition. Despite the event approach, it was not difficult to test the behavior performed by the event:

[Test]
public void FullHelpAgree_FinishLevel()
{
            // arrange
            levelGameManager.Start();
            helpMenu.ClearReceivedCalls();
            dataManager.ClearReceivedCalls();
            // act
            helpMenu.FullHelpClick += Raise.Event();
            fullHelpWindow.Agreed += Raise.Event>(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>(true);
            // assert
            dataManager.Received().SaveGame();
            levelFinishedMenu.Received().Show();
}

In integration tests, I also tested classes for the editor, and took them from the static context of the DI container. Thus checking including the correct injection, which is no less important than the unit test.

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(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 {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 {0}, false, modification);
            foreach (var variant in variants)
            {
                circuitEditorPlacer.PlaceCircuit(variant);
                var solutions = solver.FindSolutions();
                CollectionAssert.IsNOTEmpty(solutions);
            }
}

This test uses real implementations of all dependencies and also sets objects on the stage, which is quite possible in EditMode tests. It is true to test that it placed them sane - I have little idea of ​​how, so I check that the posted circuit has solutions.

In integration, there are also tests for CircuitGenerator (StructureGenerator + VariantsGenerator) and Solver

public class CircuitGeneratorTests
    {
        private ICircuitGenerator circuitGenerator;
        private ISolver solver;
        [SetUp]
        public void Setup()
        {
            solver = new Solver();
            var gates = new List>
            {
                () => new And(),
                () => new Or(),
                () => new Xor()
            };
            var conductors = new List>
            {
                () => new Conductor(),
                () => new Not()
            };
            var elements = Substitute.For();
            elements.Conductors.Returns(conductors);
            elements.Gates.Returns(gates);
            var structGenerator = new StructureGenerator();
            var variantsGenerator = new VariantsGenerator(solver, elements);
            circuitGenerator = new CircuitGenerator(structGenerator, variantsGenerator);
        }
        [Test]
        public void Generate_2l_2max_ReturnsVariants()
        {
            // act
            var variants = circuitGenerator.Generate(2, 2, new[] {0, 1, 2}, false).ToArray();
            // assert
            Assert.True(variants.Any());
            AssertLayersNotContains(variants.First().Structure.Gates, typeof(Nand));
            AssertLayersNotContains(variants.First().Structure.Gates, typeof(Nor));
            AssertLayersNotContains(variants.First().Structure.Gates, typeof(Xnor));
            AssertLayersNotContains(variants.First().Structure.Conductors, typeof(Not));
            AssertLayersContains(variants.First().Structure.Gates, typeof(Or));
            AssertLayersContains(variants.First().Structure.Gates, typeof(Xor));
            AssertLayersContains(variants.First().Structure.Conductors, typeof(Conductor));
        }
        [Test]
        public void Generate_2l_2max_RestrictedElementsWithConductors()
        {
            // arrange
            var available = new[] {0};
            // act
            var variants = circuitGenerator.Generate(2, 2, available, true).ToArray();
            // assert
            Assert.True(variants.Any());
            var lElements = new List();
            var layers = variants.Select(v => v.Gates);
            foreach (var layer in layers)
            {
                foreach (var item in layer.Values)
                {
                    lElements.AddRange(item);
                }
            }
            Assert.True(lElements.Contains(0));
            Assert.False(lElements.Contains(1));
            Assert.False(lElements.Contains(2));
            AssertLayersContains(variants.First().Structure.Gates, typeof(And));
            AssertLayersContains(variants.First().Structure.Conductors, typeof(Conductor));
            AssertLayersContains(variants.First().Structure.Conductors, typeof(Not));
            AssertLayersNotContains(variants.First().Structure.Gates, typeof(Nand));
            AssertLayersNotContains(variants.First().Structure.Gates, typeof(Or));
            AssertLayersNotContains(variants.First().Structure.Gates, typeof(Nor));
            AssertLayersNotContains(variants.First().Structure.Gates, typeof(Xnor));
            AssertLayersNotContains(variants.First().Structure.Gates, typeof(Xor));
        }
        private static void AssertLayersContains(IDictionary layers, Type elementType)
        {
            AssertLayersContains(layers, elementType, true);
        }
        private static void AssertLayersNotContains(IDictionary layers, Type elementType)
        {
            AssertLayersContains(layers, elementType, false);
        }
        private static void AssertLayersContains(IDictionary layers, Type elementType, bool shouldContain)
        {
            bool contains = false;
            foreach (var layer in layers)
            {
                foreach (var item in layer.Value)
                {
                    contains |= item.Elements.Select(e => e.GetType()).Contains(elementType);
                }
            }
            Assert.AreEqual(shouldContain, contains);
        }
    }
}


PlayMode tests are used as system tests. They check prefabs, injection, etc. A good option is to use ready-made scenes in which the test only loads and produces some interactions. But I use a prepared empty scene for testing, in which the environment is different from what will be in the game. There was an attempt to use PlayMode to test the entire game process, such as entering the menu, entering the level, and so on, but the work of these tests turned out to be unstable, so it was decided to postpone it for later (never).

It’s convenient to use coverage assessment tools to write tests, but unfortunately I haven’t found any solutions working with Unity.

I found a problem that with Unity upgrading to 2018.3, tests began to work much slower, up to 10 times slower (in a synthetic example). The project contains 288 EditMode tests that run for 11 seconds, although nothing has been done there for so long.

Development Summary


Content


Screenshot of the game level The

logic of some games can be formulated regardless of the platform. At an early stage, this gives ease of development and testability by autotests.

DI is convenient. Even taking into account the fact that Unity does not have it natively, the screwed on the side works quite tolerably.

Unity allows you to automatically test a project. True, since all built-in GameObject components have no interfaces and can only be used directly to mock things like Collider, SpriteRenderer, MeshRenderer, etc. will not work. Although GetComponent allows you to get components on the interface. As an option, write your own wrappers for everything.

Using autotests simplified the process of generating the initial logic, while there was no user interface to the code. Tests several times found an error immediately during development. Naturally, errors appeared further, but often it was possible to write additional tests / modify existing ones and later automatically catch it. Errors with DI, prefabs, scriptable objects and the like, tests are difficult to catch, but it is possible, because you can use real installers for Zenject, which will tighten the dependencies, as it happens in the build.

Unity generates a huge amount of errors, crashes. Often errors are resolved by restarting the editor. Faced with a strange loss of references to objects in prefabs. Sometimes the prefab by reference becomes destroyed (ToString () returns “null”), although everything looks working, the prefab is dragged onto the scene and the link is not empty. Sometimes some connections are lost in all scenes. Everything seems to be installed, it worked, but when switching to another branch, all the scenes are broken - there are no links between the elements.

Fortunately, these errors were often corrected by restarting the editor or sometimes deleting the Library folder.

In total, about half a year has passed from the idea to publication on Google Play. The development itself took about 3 months, in free time from the main work.

Also popular now: