
I want to share the process of developing a simple mobile game by two developers and an artist. This article is more a description of the technical implementation.
Careful, a lot of text!
The article is not a guide or a lesson, although I hope that readers will be able to learn something useful from it. Designed for developers familiar with Unity who have some programming experience.
Content:
IdeaGameplayPlotDevelopmentCore- Electrical items
- Solver
- ElementsProvider
- CircuitGenerator
Game classes')
- Development Approach and DI
- Configuration
- Electrical items
- Game management
- Loading levels
- Cutscenes
- Extra gameplay
- Monetization
- User interface
- Analytics
- Camera positioning and diagrams
- Color schemes
Editor extensions
- Generator
- Solver
Useful- Asserthelper
- SceneObjectsHelper
- CoroutineStarter
- Gizmo
TestingDevelopment resultsIdea
ContentAn idea to make a simple mobile game, for a short period.
Conditions:
- Easy to implement game
- Minimum requirements for art
- Short development time (several months)
- With easy automation of content creation (levels, locations, game elements)
- Quick level creation if the game consists of a finite number of levels
In order to determine what to actually do? After all, the idea to make a game, and not the idea of ​​the game. It was decided to look for inspiration in the app store.
To the above points are added:
- The game must have a certain popularity among the players (number of downloads + ratings)
- The app store should not be filled with similar games.
A game was found with a gameplay based on logic gates. There were no similar ones in a larger number. The game has many downloads and positive ratings. Nevertheless, having tried there were some flaws that can be taken into account in your game.
The gameplay of the game is that the level is a digital circuit with multiple inputs and outputs. The player must choose such a combination of inputs so that the output is logical 1. It does not sound very difficult. Also, the game 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 AutoTests
- Ability to auto-generate levels
Minuses:
- You must first create levels
Now explore the shortcomings of the game which were inspired.
- Not adapted for non-standard 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.
- In the reviews complained about the lack of diversity of elements
We proceed to the planning of our game:
- We use standard logic gates (AND, NAND, OR, NOR, XOR, XNOR, NOR, NOT)
- Gates display 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. For the reason that he demands to press on himself and does not fit into the real digital elements a little. And it's hard to imagine a toggle switch in a microcircuit.
- Add the elements Encoder and Decoder.
- Enter the mode in which the player must select the desired element in the cell with fixed values ​​at the inputs of the scheme.
- We implement player assistance: hint + skip level.
- It would be nice to add some plot.
Gameplay
ContentMode 1: The player receives the scheme and has access to change the values ​​at the inputs.
Mode 2: The player receives a scheme in which he can change the elements but cannot change the values ​​at the inputs.
The gameplay will be done in the form of pre-prepared levels. After passing 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 may be the passing indicators:
Number of actions: Each interaction with game elements 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 pass. Unfortunately, it does not fit with the second mode.
It would be nice to add the same mode with random level generation. But for now, let's postpone it for later.
Plot
ContentWhile thinking about the gameplay and starting to develop different ideas appeared to improve the game. And there was quite an interesting idea - add a story.
It is about an engineer who designs circuits. Not bad, but the completeness is not felt. It is probably worth displaying the manufacture of chips based on what the player does? As a matter of routine, there is no clear and simple result.
Idea! The engineer develops a cool robot using his logic circuits. The robot is quite simple understandable thing and perfectly fit with the gameplay.
Remember the first item “Minimum requirements for art”? Something does not fit with the cutscenes in the plot. Here comes to the aid of a familiar artist, who agreed to help us.
Now we will define the format and integration of the cutscene into the game.
The plot must be displayed as a cutscene without sound or text description that will remove problems with localization, simplify its understanding, and many people play on mobile devices without sound. The game is a very real elements of digital circuits, that is, it is quite possible to link it with reality.
The cutscenes and levels must be separate scenes. Before a certain level, a specific scene is loaded.
Well, the task has been set, there are resources for execution, the work has begun to boil.
Development
ContentWith a platform was defined at once, it is Unity. Yes, a little overkill, but nevertheless I am familiar with it.
During the development process, 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
ContentThe 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 of the logic. We carry it out in a separate project.
Unity works with the C # solution and projects inside a bit unusual for a regular .Net developer, the .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. All that is in the folder with the .asmdef file will be related to this project and build.
Electrical items
ContentThe goal is to describe in the code the interaction of logical elements with each other.
- An item 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 operation logic 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, because a value is requested for a specific output, the default is 0.
- To take values ​​at the input, there will be an input connection p, it stores a link to another - an output connector.
- The output connector refers to a specific element and stores a link to its element; when a value is requested, 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 connector interface. You can get 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; } }
The 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 asks for a value.
public interface IElectricalElement { bool GetValue(byte number = 0); }
The electrical element must contain a method that returns a value at a particular output, number is the output number.
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, it simply conveys a value, like a conductor.We now turn 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, it requests the value from the element.
public abstract class ElectricalElementBase { public IInputConnector[] Input { get; set; } }
The base class for all elements simply contains an array of inputs.
Example of element implementation:
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 clearly as with a table, but flexibly, 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 the GetValue method is overlapped here, and not redefined virtually. This is done based on the logic that if Nand someone speaks up to And, then he will continue to behave like And. It was also possible to apply the composition, but this would require extra code that does not make much sense.
In addition to conventional valves, the following elements were created:
Source - the source of the constant value 0 or 1.
Conductor is simply a conductor of the same Or, only has a slightly different use, see generation.
AlwaysFalse - always returns 0, it is necessary for the second mode.
Solver
ContentNext, a class is useful for automatically finding combinations that yield 1 on the output of the circuit.
public interface ISolver { ICollection<bool[]> GetSolutions(IElectricalElement root, params Source[] sources); } public class Solver : ISolver { public ICollection<bool[]> GetSolutions(IElectricalElement root, params Source[] sources) {
Solutions are easy to find. 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. Enumerate all numbers from 0 to 15.
ElementsProvider
ContentFor convenience of generation, I decided to define a number for each element. To do this, I created the ElementsProvider class with the IElementsProvider integrarace.
public interface IElementsProvider { IList<Func<IElectricalElement>> Gates { get; } IList<Func<IElectricalElement>> Conductors { get; } IList<ElectricalElementType> GateTypes { get; } IList<ElectricalElementType> ConductorTypes { get; } } public class ElementsProvider : IElementsProvider { public IList<Func<IElectricalElement>> Gates { get; } = new List<Func<IElectricalElement>> { () => new And(), () => new Nand(), () => new Or(), () => new Nor(), () => new Xor(), () => new Xnor() }; public IList<Func<IElectricalElement>> Conductors { get; } = new List<Func<IElectricalElement>> { () => new Conductor(), () => new Not() }; public IList<ElectricalElementType> GateTypes { get; } = new List<ElectricalElementType> { ElectricalElementType.And, ElectricalElementType.Nand, ElectricalElementType.Or, ElectricalElementType.Nor, ElectricalElementType.Xor, ElectricalElementType.Xnor }; public IList<ElectricalElementType> ConductorTypes { get; } = new List<ElectricalElementType> { ElectricalElementType.Conductor, ElectricalElementType.Not }; }
The first two lists are something like factories that give an element at a specified number. The last two lists are a crutch which should be used because of features of Unity. About this further.
CircuitGenerator
ContentNow the most difficult part of the development is schema generation.
The task is to generate a list of schemes from which in the editor you can then choose the one you like. Generation is needed only for simple valves.
Certain parameters of the scheme are specified: the number of layers (horizontal lines of the elements) and the maximum number of elements in the layer. You also need to determine from which gates you need to generate circuits.
My approach was to split the task into two parts - the generation of the structure and the selection of options.
The structure generator determines the positions and connections of the logic elements.
The variant generator selects valid combinations of elements in the positions.
StructureGenerator
The structure consists of layers of logic elements and layers of conductors / inverters. The whole structure contains not 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
The container can set itself in one of the items from the list. During initialization, you must pass it a list of delegates who will create the elements. Inside, it calls each delegate and gets the item. Then you can set a specific type of this element, it 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 to set the list of items:
public void SetElements(IList<Func<IElectricalElement>> elements) { Elements = new List<IElectricalElement>(elements.Count); foreach (var item in elements) { Elements.Add(item()); } }
Then you can set the type as follows:
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 element.
This structure was created for the scheme:
public class CircuitStructure : ICloneable { public IDictionary<int, ElectricalElementContainer[]> Gates; public IDictionary<int, ElectricalElementContainer[]> Conductors; public Source[] Sources; public And FinalDevice; }
Dictionaries here store the layer number in the key and the array of containers for this layer. Next, an array of sources and one FinalDevice to which everything is connected.
Thus, the structural generator creates containers and connects them to each other. It is created all in layers, from bottom to top. Bottom is the widest (most elements). The layer above contains two times less elements and so on until we reach the minimum. Outputs of all elements of the upper layer are connected to the final device.
A layer of logic elements contains containers for valves. In the layer of conductors are elements with one input and output. Elements there can be either a conductor or an element of NO. The conductor transmits to the output what came to the input, and the element NO returns the inverted value at the output.
The first is an array of sources. The generation takes place from the bottom up, the layer of conductors is first generated, the logic layer is farther, and the conductors are again at the output.

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 joining through multiple layers.
Well, to say “simplified” - it means complicating your life in something else.
The generation of schemes with the maximum level of modification turned out to be a laborious and not very practical task. Therefore, our team decided to do something that met these criteria:
The development of this task took not 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 ̶u̶zh̶a̶s̶n̶̶m̶s̶ya̶.
There is a class OverflowArray. For historical reasons, it was added after the basic structural generation and has more to do with the generation of variants, therefore it is located lower. Link public IEnumerable<CircuitStructure> GenerateStructure(int lines, int maxElementsInLine, StructureModification modification) { var baseStructure = GenerateStructure(lines, maxElementsInLine); for (int i = 0; i < lines; i++) { int maxValue = 1; int branchingSign = 1; if (modification == StructureModification.All) { maxValue = 2; branchingSign = 2; } int lengthOverflowArray = baseStructure.Gates[(i * 2) + 1].Length; var elementArray = new OverflowArray(lengthOverflowArray, maxValue); double numberOfOption = Math.Pow(2, lengthOverflowArray); for (int k = 1; k < numberOfOption - 1; k++) { elementArray.Increase(); if (modification == StructureModification.Branching || modification == StructureModification.All) { if (!CheckOverflowArrayForAllConnection(elementArray, branchingSign, lengthOverflowArray)) { continue; } }
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 (basic) 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? It will be clear further.
int maxValue = 1; int branchingSign = 1; if (modification == StructureModification.All) { maxValue = 2; branchingSign = 2; }
Now we define 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 structure manipulations, we need to know the number of possible variations of our OverflowArray. For this there is a formula that was applied in the next line.
int lengthOverflowArray = baseStructure.Gates[(i * 2) + 1].Length;
Next comes the nested loop in which all the “magic” occurs and for which all this preface was. At the very beginning, we produce an increase in the values ​​of our array.
elementArray.Increase();
After that we see a validation check, as a result of which we move on or on to the next iteration.
if (modification == StructureModification.Branching || modification == StructureModification.All) { if (!CheckOverflowArrayForAllConnection(elementArray, branchingSign, lengthOverflowArray)) { continue; } }
If the validation of the array passes the test, then we clone our basic structure. Cloning is necessary, since we will modify our structure for many more iterations.
And finally, we proceed to the modification of the structure and its cleaning of unnecessary elements. Unnecessary they became as a result of the modification of the structure.
ModifyStructure(structure, elementArray, key, modification); ClearStructure(structure);
I don’t see the point of disassembling dozens of minor functions that are performed “somewhere out there” in depth.
VariantsGenerator
The structure + elements that should be in it is called CircuitVariant.
public struct CircuitVariant { public CircuitStructure Structure; public IDictionary<int, int[]> Gates; public IDictionary<int, int[]> Conductors; public IList<bool[]> Solutions; }
The first field is a link to the structure. The second two dictionaries in which the key is the layer number, and the value is an array that contains the element numbers in their places in the structure.
We proceed to the selection of combinations. We may have a certain number of valid logic elements and conductors. Total logical elements can be 6 a conductors 2.
You can imagine a number system with a base of 6 and in each digit get numbers that correspond to the elements. Thus, by increasing this hexadecimal number, you can loop through all combinations of elements.
That is, a hexadecimal number of three digits will be 3 elements. It is only worth considering that the number of elements not 6 and 4 can be transferred.
For the discharge of such a number, I defined 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; }
Next is a class with a strange name
OverflowArray . Its essence is that it stores the
ClampedInt array and increases the most significant bit if an overflow has occurred in the
low -order bit and so on until it reaches the maximum value in all the cells.
In accordance with each ClampedInt, the values ​​of the corresponding ElectricalElementContainer are set. This way you can sort through all possible combinations. It should be noted that if it is necessary to generate a circuit with elements (for example, And (0) and Xor (4)), it is not necessary to go through all the options including the elements 1,2,3. For this, during generation, the elements get their local numbers (for example, And = 0, Xor = 1), and then they are converted back to global numbers.
So you can iterate through all possible combinations in all elements.
After the values ​​in the containers are set, the schema is checked for solutions for it, using
Solver . If the scheme has passed the decision, it will be returned.
After the circuit is generated, it checks the number of solutions. It should not exceed the limit and should not have solutions consisting entirely of 0 or 1.
Lot of code public interface IVariantsGenerator { IEnumerable<CircuitVariant> Generate(IEnumerable<CircuitStructure> structures, ICollection<int> availableGates, bool useNot, int maxSolutions = int.MaxValue); } public class VariantsGenerator : IVariantsGenerator { private readonly ISolver solver; private readonly IElementsProvider elementsProvider; public VariantsGenerator(ISolver solver, IElementsProvider elementsProvider) { this.solver = solver; this.elementsProvider = elementsProvider; } public IEnumerable<CircuitVariant> Generate(IEnumerable<CircuitStructure> structures, ICollection<int> availableGates, bool useNot, int maxSolutions = int.MaxValue) { bool manyGates = availableGates.Count > 1; var availableLeToGeneralNumber = GetDictionaryFromAllowedElements(elementsProvider.Gates, availableGates); var gatesList = GetElementsList(availableLeToGeneralNumber, elementsProvider.Gates); var availableConductorToGeneralNumber = useNot ? GetDictionaryFromAllowedElements(elementsProvider.Conductors, new[] {0, 1}) : GetDictionaryFromAllowedElements(elementsProvider.Conductors, new[] {0}); var conductorsList = GetElementsList(availableConductorToGeneralNumber, elementsProvider.Conductors); foreach (var structure in structures) { InitializeCircuitStructure(structure, gatesList, conductorsList); var gates = GetListFromLayersDictionary(structure.Gates); var conductors = GetListFromLayersDictionary(structure.Conductors); var gatesArray = new OverflowArray(gates.Count, availableGates.Count - 1); var conductorsArray = new OverflowArray(conductors.Count, useNot ? 1 : 0); do { if (useNot && conductorsArray.EqualInts) { continue; } SetContainerValuesAccordingToArray(conductors, conductorsArray); do { if (manyGates && gatesArray.Length > 1 && gatesArray.EqualInts) { continue; } SetContainerValuesAccordingToArray(gates, gatesArray); var solutions = solver.GetSolutions(structure.FinalDevice, structure.Sources); if (solutions.Any() && solutions.Count <= maxSolutions && !(solutions.Any(s => s.All(b => b)) || solutions.Any(s => s.All(b => !b)))) { var variant = new CircuitVariant { Conductors = GetElementsNumberFromLayers(structure.Conductors, availableConductorToGeneralNumber), Gates = GetElementsNumberFromLayers(structure.Gates, availableLeToGeneralNumber), Solutions = solutions, Structure = structure }; yield return variant; } } while (!gatesArray.Increase()); } while (useNot && !conductorsArray.Increase()); } } private static void InitializeCircuitStructure(CircuitStructure structure, IList<Func<IElectricalElement>> gates, IList<Func<IElectricalElement>> conductors) { var lElements = GetListFromLayersDictionary(structure.Gates); foreach (var item in lElements) { item.SetElements(gates); } var cElements = GetListFromLayersDictionary(structure.Conductors); foreach (var item in cElements) { item.SetElements(conductors); } } private static IList<Func<IElectricalElement>> GetElementsList(IDictionary<int, int> availableToGeneralGate, IReadOnlyList<Func<IElectricalElement>> elements) { var list = new List<Func<IElectricalElement>>(); foreach (var item in availableToGeneralGate) { list.Add(elements[item.Value]); } return list; } private static IDictionary<int, int> GetDictionaryFromAllowedElements(IReadOnlyCollection<Func<IElectricalElement>> allElements, IEnumerable<int> availableElements) { var enabledDic = new Dictionary<int, bool>(allElements.Count); for (int i = 0; i < allElements.Count; i++) { enabledDic.Add(i, false); } foreach (int item in availableElements) { enabledDic[item] = true; } var availableToGeneralNumber = new Dictionary<int, int>(); int index = 0; foreach (var item in enabledDic) { if (item.Value) { availableToGeneralNumber.Add(index, item.Key); index++; } } return availableToGeneralNumber; } private static void SetContainerValuesAccordingToArray(IReadOnlyList<ElectricalElementContainer> containers, IOverflowArray overflowArray) { for (int i = 0; i < containers.Count; i++) { containers[i].SetType(overflowArray[i].Value); } } private static IReadOnlyList<ElectricalElementContainer> GetListFromLayersDictionary(IDictionary<int, ElectricalElementContainer[]> layers) { var elements = new List<ElectricalElementContainer>(); foreach (var layer in layers) { elements.AddRange(layer.Value); } return elements; } private static IDictionary<int, int[]> GetElementsNumberFromLayers(IDictionary<int, ElectricalElementContainer[]> layers, IDictionary<int, int> elementIdToGlobal = null) { var dic = new Dictionary<int, int[]>(layers.Count); bool convert = elementIdToGlobal != null; foreach (var layer in layers) { var values = new int[layer.Value.Length]; for (int i = 0; i < layer.Value.Length; i++) { if (!convert) { values[i] = layer.Value[i].SelectedType; } else { values[i] = elementIdToGlobal[layer.Value[i].SelectedType]; } } dic.Add(layer.Key, values); } return dic; } }
Each of the generators returns its own variant using the yield operator. Thus, CircuitGenerator using the StructureGenerator and the VariantsGenerator generates an IEnumerable. (The yield approach helped well in the future, see below).Following from the fact that the variant generator gets a list of structures. You can generate variants for each structure independently. This could be parallelized, but adding AsParallel did nothing (probably yield prevents). Manually parallelizing will be a long time, therefore we reject this option. Actually, I tried to do parallel generation, it worked, but there were some difficulties, therefore it did not go to the repository.Game classes
Development Approach and DI
ContentThe project is built under Dependency Injection (DI). This means that classes can simply claim to themselves some object corresponding to the interface and not create this object. What are the advantages:- The place of creation and initialization of the dependency object is defined in one place and is separated from the logic of the dependent classes, which removes duplication of code.
- Eliminates the need to dig out the entire dependency tree and instantiate all dependencies.
- Allows you to easily change the implementation of the interface, which is used in many places.
Like a DI container in a project, Zenject is used .Zenject has several contexts, I use only two of them:- The project context is the registration of dependencies throughout the entire application.
- : .
- , .
Registration of classes is stored in the Installer . For the context of the project, I use ScriptableObjectInstaller , and for the context of the scene - MonoInstaller .Most of the classes I register are AsSingle, since they do not contain states, rather they are simply containers for methods. AsTransient I use 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. Classes associated with Unity, I also singled out a separate project dependent 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 members of MonoBehaviour.For convenience, DI often create a simple class that performs all the logic, and MonoBehaviour a 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 into the constructor, the detachment of the main class from the DI container and the ability to easily test.Configuration
ContentBy configuration, I mean data common to the whole application. In my case, these are prefabs, identifiers for advertising and shopping, tags, scene names, etc. For these purposes, I use ScriptableObjects:- For each data group, the class is derived from ScriptableObject.
- It creates the necessary serializable fields.
- Properties are added to read from these fields.
- The interface with the above fields is highlighted.
- The class is registered to the interface in a DI container.
- 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)); } }
To configure a separate installer (code abbreviated): CreateAssetMenu(fileName = nameof(ConfigurationInstaller), menuName = "Installers/" + nameof(ConfigurationInstaller))] public class ConfigurationInstaller : ScriptableObjectInstaller<ConfigurationInstaller> { [SerializeField] private EditorElementsPrefabs editorElementsPrefabs; [SerializeField] private LevelCompletionSteps levelCompletionSteps; [SerializeField] private CommonValues commonValues; [SerializeField] private AdsConfiguration adsConfiguration; [SerializeField] private CutscenesConfiguration cutscenesConfiguration; [SerializeField] private Colors colors; [SerializeField] private Tags tags; public override void InstallBindings() { Container.Bind<IEditorElementsPrefabs>().FromInstance(editorElementsPrefabs).AsSingle(); Container.Bind<ILevelCompletionSteps>().FromInstance(levelCompletionSteps).AsSingle(); Container.Bind<ICommonValues>().FromInstance(commonValues).AsSingle(); Container.Bind<IAdsConfiguration>().FromInstance(adsConfiguration).AsSingle(); Container.Bind<ICutscenesConfiguration>().FromInstance(cutscenesConfiguration).AsSingle(); Container.Bind<IColors>().FromInstance(colors).AsSingle(); Container.Bind<ITags>().FromInstance(tags).AsSingle(); } private void OnEnable() { editorElementsPrefabs.AssertNotNull(); levelCompletionSteps.AssertNotNull(); commonValues.AssertNotNull(); adsConfiguration.AssertNotNull(); cutscenesConfiguration.AssertNotNull(); colors.AssertNOTNull(); tags.AssertNotNull(); } }
Electrical items
ContentNow we need to somehow introduce 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; }
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; }
But how to install this item?A good option would be to make generic:public class ElectricalElementMb: MonoBehaviour, IElectricalElementMb where T: IElectricalElementBut here’s the catch that Unity does not support generic MonoBehaviour classes. Moreover, Unity does not support the serialization of properties and interfaces.However, in runtime, it is quite possible to pass in an 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 is beautifully displayed in the Inspector as a drop-down list. Defined two kinds 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 initializes them depending on the type in the enum field. In the following way: private static readonly Dictionary<ElectricalElementType, Func<IElectricalElement>> ElementByType = new Dictionary<ElectricalElementType, Func<IElectricalElement>> { {ElectricalElementType.And, () => new And()}, {ElectricalElementType.Or, () => new Or()}, {ElectricalElementType.Xor, () => new Xor()}, {ElectricalElementType.Nand, () => new Nand()}, {ElectricalElementType.Nor, () => new Nor()}, {ElectricalElementType.NOT, () => new NOT()}, {ElectricalElementType.Xnor, () => new Xnor()}, {ElectricalElementType.Source, () => new Source()}, {ElectricalElementType.Conductor, () => new Conductor()}, {ElectricalElementType.Placeholder, () => new AlwaysFalse()}, {ElectricalElementType.Encoder, () => new Encoder()}, {ElectricalElementType.Decoder, () => new Decoder()} };
Game management
ContentNext, the question arises, where to locate 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 single out certain classes of managers who are responsible for a certain class of tasks.DataManager is responsible for storing the data of the results of the user's passage and setting up the game. It is registered by AsSingle in the context of the project. This means that it is one for the entire application. While the application is running, data is stored directly in memory, inside the DataManager.It uses the IFileStoreService , which is responsible for loading and saving data and the IFileSerializerresponsible for serializing files into a ready-made view for saving.LevelGameManager is a game manager in a single scene.I got it a little GodObject, since he is still responsible for the UI, that is, opening and closing the menu, the reaction to the buttons. But it is permissible, given the size of the project and the absence of the need to expand it. The sequence of actions is even simpler and more clearly visible.There are two options. They are called LevelGameManager1 and LevelGameManager2 for mode 1 and 2, respectively.In the first case, the logic is based on the reaction to a value change event in one of the Sources and checking the value at the output of the circuit.In the second case, the logic responds to the element change event and also checks the values ​​at the output of the circuit.There are some data of the current level, such as the level number and player assistance.Data about the current level is stored in CurrentLevelData . The level number is stored there - a boolean property with a check for help, a 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<SourcePositionValueHelp> PartialHelp { get; } } public interface ICurrentLevelDataMode2 : ICurrentLevelData { IEnumerable<PlaceTypeHelp> PartialHelp { get; } }
The help for the first mode is the source numbers and values ​​on them. In the second mode, this is the type of element to be installed in the cell.The collection contains structures that store the position and the value that needs to be set at the specified position. A dictionary would be more beautiful, but Unity cannot serialize dictionaries.Differences scenes of different modes are that in the context of the scene set another LevelGameManager and another ICurrentLevelData .In general, I have an event-oriented approach to the connection of elements. On the one hand, it is logical and convenient. On the other hand, it is possible to get problems without unsubscribing when necessary. Nevertheless, there were no problems in this project, and the scale was not too large. Usually subscription occurs during the start of the scene for everything you need. In runtime, almost nothing is created, so there is no confusion.Loading levels
ContentEach level in the game is represented by a Unity-scene, it necessarily contains a level prefix and a number, for example “Level23”. The prefix is ​​included in the configuration. Level loading occurs by name, which is formed from the prefix. Thus, the LevelsManager class can load levels by number.Cutscenes
ContentThe cutscenes are common unity scenes with numbers in the title, similar to levels.The animation itself is implemented using Timeline. Unfortunately, I don’t have any animation skills or the ability to work with the Timeline, so “do not shoot the pianist - he plays as he can”.
It turned out that one logical cutscene should consist of different scenes with different objects. It turned out that it was noticed a little late, but it was decided simply: by locating parts of the cutscenes in different places on the stage and instantly moving the camera.
Extra gameplay
ContentThe game is evaluated by the number of actions at the level and the use of hints. The less action the better. Using the tooltip reduces the maximum rating to 2 stars, skipping a level to 1 star. To assess the passage is stored the number of steps to pass. It consists of two values: the minimum value (for 3 stars) and maximum (1 star).The number of steps to complete the 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 passed level. This complicated the process of creating levels a little. 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 it is not firstly serialized by Unity, and secondly it would have to manually set the numbers.If a player is difficult to pass a 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, a player gets 1 star for it.A user who has passed a level with a hint cannot rewrite it for a while, so that it would be difficult to re-level the level with fresh memory, as if without a hint.monetization
ContentThe game has two types of monetization: the display of advertising and disabling advertising for money. An ad impression includes displaying advertisements between levels and viewing rewarded advertisements to skip a level.If the player is willing to pay for disabling advertising, he can do it. In this case, ads between the levels and when skipping the level will not be displayed.For advertising created a class called AdsService , with the 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; } }
HelpAd is a rewarding ad for level skipping. Initially, we called help partial and full help. Partial is a hint, and complete is a level skip.This class contains within the limitation of the frequency of display of advertising on time, after the first launch of the game.The implementation uses the Google Mobile Ads Unity Plugin .With rewarded advertising, I stepped on a rake - it turns out that devotees can be called in another thread, it’s not very clear why. Therefore, it is better that those delegates would not call anything in the code associated with Unity. In the event that a purchase has been made to disable advertising, the advertising will not be displayed and the delegate of the successful display of advertising will be executed immediately.There is an interface for shopping. public interface IPurchaseService { bool IsAdsDisablePurchased { get; } event Action DisableAdsPurchased; void BuyDisableAds(); void RemoveDisableAd(); }
The implementation uses Unity IAPWith the purchase of disabling advertising there is a trick. Google Play does not seem to provide information about what the player has bought some kind of purchase. Just come confirmation that it passed once. But if you put the product status after the purchase is not Complete, but Pending, this will allow you to check the property of the hasReceipt product . If it is true then the purchase was made.Although of course this approach confuses me. I suspect that all may not be going smoothly.The RemoveDisableAd method is needed at the time of testing, it removes the purchased disable advertising.User interface
ContentAll interface elements work in accordance with the event-oriented approach. The interface elements themselves usually do not contain logic except events caused by public methods that Unity can use. Although it also happens to perform certain 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. For good, you should leave these elements as active View, make an event listener from it, something like a controller that will trigger the necessary actions on the managers.Analytics
ContentAlong the path of least resistance, an analyst from Unity was chosen . Easy to implement, although limited to a free subscription - it’s impossible to export raw data. There is also a limit on the number of events - 100 / hour per player.For analytics, created a wrapper class AnalyticsService . It has methods for each type of event, receives the necessary parameters, and causes the event to be sent using tools built into Unity. Creating a method for each event is certainly not the best practice in general, but in a deliberately small project it is better than doing something big and complicated.All used events are CustomEvent.. They are built from the name of the event and the dictionary parameter name and value. AnalyticsService gets the necessary values ​​from the parameters and creates a dictionary inside.All the names of events and parameters are in constants. Not in the form of a traditional approach with ScriptableObject, since these values ​​should never change.Example method: public void LevelComplete(int number, int stars, int actionCount, TimeSpan timeSpent, int levelMode) { CustomEvent(LevelCompleteEventName, new Dictionary<string, object> { {LevelNumber, number}, {LevelStars, stars}, {LevelActionCount, actionCount}, {LevelTimeSpent, timeSpent}, {LevelMode, levelMode} }); }
Camera positioning and diagrams
ContentThe task is to position FinalDevice at the top of the screen, at the same distance from the upper boundary, and Sources from the bottom is also always at an equal distance from the lower boundary. 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 can fit the circuit correctly.For this, the CameraAlign class is created . Algorithm for determining the size:- Find all the items you want on stage
- Find the minimum width and height based on the aspect ratio
- Determine camera size
- Set the camera in the center
- Move FinalDevice to the top edge of the screen
- Move sources to the bottom edge 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<IElectricalElementMb>(); var finalDevice = sceneObjectsHelper.FindObjectOfType<IFinalDevice>(); var sources = elements.OfType<ISourceMb>().ToArray(); if (finalDevice != null && sources.Length > 0) { float leftPos = elements.Min(s => s.Transform.position.x); float rightPos = elements.Max(s => s.Transform.position.x); float width = Mathf.Abs(leftPos - rightPos); var fPos = finalDevice.Transform.position; float height = Mathf.Abs(sources.First().Transform.position.y - fPos.y) * camera.aspect; float size = Mathf.Max(width * commonValues.CameraOffset, height * commonValues.CameraOffset); camera.orthographicSize = Mathf.Clamp(size, commonValues.MinCameraSize, float.MaxValue); camera.transform.position = GetCenterPoint(elements, -1); fPos = new Vector2(fPos.x, camera.ScreenToWorldPoint(new Vector2(Screen.width, Screen.height)).y - commonValues.FinalDeviceTopOffset * camera.orthographicSize); finalDevice.Transform.position = fPos; float sourceY = camera.ScreenToWorldPoint(Vector2.zero).y + commonValues.SourcesBottomOffset; foreach (var item in sources) { item.Transform.position = new Vector2(item.Transform.position.x, sourceY); } } else { Debug.Log($"{nameof(CameraAlign)}: No final device or no sources in scene"); } } private static Vector3 GetCenterPoint(ICollection<IElectricalElementMb> elements, float z) { float top = elements.Max(e => e.Transform.position.y); float bottom = elements.Min(e => e.Transform.position.y); float left = elements.Min(e => e.Transform.position.x); float right = elements.Max(e => e.Transform.position.x); float x = left + ((right - left) / 2); float y = bottom + ((top - bottom) / 2); return new Vector3(x, y, z); } }
This method is called when a scene is started in a wrapper class.Color schemes
ContentSince the game will have a very primitive interface decided to make it with two color schemes, black and white.To do this, create an interface public interface IColors { Color ColorAccent { get; } Color Background { get; set; } Color Foreground { get; set; } event Action ColorsChanged; }
Colors can be set right in the Unity editor; this can be used for testing. Then you can switch them and have two sets of colors.The colors of Background and Foreground can change, the color accent is one in any mode.Since the player can set a non-standard theme, the color data should be stored in the settings file. If the settings file did not contain color data, 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 the 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, which will mean what type of color to set for it (Background, Foreground, AccentColor and FixedColor). This is all set at the start of the scene or the event of a change in color scheme.Result:


Editor extensions
ContentTo simplify and speed up the development process, it is often necessary to create the necessary tool that is not provided by standard editor tools. The traditional approach in Unity is to create the derived class EditorWindow. There is also an approach with UiElements, but it is still in the development process, 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 does not build up, 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 in the class that is required in the build, add a small part for the editor.All classes that are needed only in the editor I defined in the assembly, which will be available only in the editor. In the build of the game she will not go.
It would be nice to have DI in your editor extensions now. For this, I use Zenject.StaticContext. In order to set it in the editor, a class with an InitializeOnLoad attribute is used, in which there is a static constructor. [InitializeOnLoad] public class EditorInstaller { static EditorInstaller() { var container = StaticContext.Container; container.Bind<IElementsProvider>().To<ElementsProvider>().AsSingle(); container.Bind<ISolver>().To<Solver>().AsSingle(); .... } }
To register ScriptableObject-classes in a static context, I use the following code: BindFirstScriptableObject<ISceneNameConfiguration, SceneNameConfiguration>(container); private static void BindFirstScriptableObject<TInterface, TImplementation>(DiContainer container) where TImplementation : ScriptableObject, TInterface { var obj = GetFirstScriptableObject<TImplementation>(); container.Bind<TInterface>().FromInstance(obj).AsSingle(); } private static T GetFirstScriptableObject<T>() where T : ScriptableObject { var guids = AssetDatabase.FindAssets("t:" + typeof(T).Name); string path = AssetDatabase.GUIDToAssetPath(guids.First()); var obj = AssetDatabase.LoadAssetAtPath<T>(path); return obj; }
TImplementation is required only for this line. AssetDatabase.LoadAssetAtPath (path)You cannot put the dependency into the constructor. Instead, add the [Inject] attribute to the window class in the dependency fields and callStaticContext.Container.Inject (this) when starting the window ;I also recommend adding to the window update cycle a check for null of one of the dependency fields and, if the field is empty, perform the above line. Because after changing the code in a project, Unity may re-create the window and not cause Awake on it.Generator
Content
The initial view of the generator.The window should provide the interface for generating a list of schemes with indication of parameters, display the list of schemes and locate the selected scheme in the current scene.The window consists of three sections from left to right:- generation settings
- list of options in the form of buttons
- text selection
The columns are created using EditorGUILayout.BeginVertical () and EditorGUILayout.EndVertical (). Unfortunately, it was not possible to fix and limit the dimensions, 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 elements I. As the profiler showed, the circuit itself is the slowest part. Parallelizing it is not an option, one scheme is used for all variants, and cloning this structure is difficult.Then I thought that probably all the code extensions editor works in Debug mode. Under Release, debugging does not go 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 build launched from the console application, and this is ~ 6 times slower than Release. Keep this in mind.As an option, you can make an external assembly and add it to the Unity DLL with an assembly, but this greatly complicates the assembly and editing of the project.Immediately I passed the generation process to a separate Task with a code containing the following:circuitGenerator.Generate (lines, maxElementsInLine, availableLogicalElements, useNOT, modification) .ToList ()Already better, though the editor does not 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 schemes). Plus, there is a problem that the task is not so easy to complete and it continues to work until the generation is completed.Lot of code internal static class Ext { public static IEnumerable<CircuitVariant> OrderVariants(this IEnumerable<CircuitVariant> circuitVariants) { return circuitVariants.OrderBy(a => a.Solutions.Count()) .ThenByDescending(a => a.Solutions .Select(b => b.Sum(i => i ? 1 : -1)) .OrderByDescending(b=>b) .First()); } } public interface IEditorGenerator : IDisposable { CircuitVariant[] FilteredVariants { get; } int LastPage { get; } void FilterVariants(int page); void Start(int lines, int maxElementsInLine, ICollection<int> availableGates, bool useNOT, StructureModification? modification, int maxSolutions); void Stop(); void Fetch(); } public class EditorGenerator : IEditorGenerator { private const int PageSize = 100; private readonly ICircuitGenerator circuitGenerator; private ConcurrentBag<CircuitVariant> variants; private List<CircuitVariant> sortedVariants; private Thread generatingThread; public EditorGenerator(ICircuitGenerator circuitGenerator) { this.circuitGenerator = circuitGenerator; } public void Dispose() { generatingThread?.Abort(); } public CircuitVariant[] FilteredVariants { get; private set; } public int LastPage { get; private set; } public void FilterVariants(int page) { CheckVariants(); if (sortedVariants == null) { Fetch(); } FilteredVariants = sortedVariants.Skip(page * PageSize) .Take(PageSize) .ToArray(); int count = sortedVariants.Count; LastPage = count % PageSize == 0 ? (count / PageSize) - 1 : count / PageSize; } public void Fetch() { CheckVariants(); sortedVariants = variants.OrderVariants() .ToList(); } public void Start(int lines, int maxElementsInLine, ICollection<int> availableGates, bool useNOT, StructureModification? modification, int maxSolutions) { if (generatingThread != null) { Stop(); } variants = new ConcurrentBag<CircuitVariant>(); generatingThread = new Thread(() => { var v = circuitGenerator.Generate(lines, maxElementsInLine, availableGates, useNOT, modification, maxSolutions); foreach (var item in v) { variants.Add(item); } }); generatingThread.Start(); } public void Stop() { generatingThread?.Abort(); sortedVariants = null; variants = null; generatingThread = null; FilteredVariants = null; } private void CheckVariants() { if (variants == null) { throw new InvalidOperationException("VariantsGeneration is not started. Use Start before."); } } ~EditorGenerator() { generatingThread.Abort(); } }
The idea is to generate in the background, and update the internal list of sorted variants on request. Then you can choose options page by page. 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 decisions, by increase and by how diverse the values ​​are required for the solution. That is, the scheme with the solution 1 1 1 1 is less interesting than 1 0 1 1.
Thus, it turned out, without waiting for the end of the generation, to select the scheme for the level. Another plus that because of the pagination editor does not slow down like a beast.The feature of Unity is very disturbing in that when you click on Play, the contents of the window are reset, as are all generated data. If they were easily serializable, they could be stored as files. Thus, you can even make caching the results of the generation. But alas, it is difficult to serialize a complex structure where objects refer to each other.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
ContentWhen you collect the scheme in the editor, you need to be able to quickly understand whether it is solved and how many solutions it has. For this, I created a “solver” window. It provides solutions to the current scheme in the form of text.
The logic of its “backend” operation: public string GetSourcesLabel() { var sourcesMb = sceneObjectsHelper.FindObjectsOfType<SourceMb>().OrderBy(s => s.name); var sourcesLabelSb = new StringBuilder(); foreach (var item in sourcesMb) { sourcesLabelSb.Append($"{item.name.Replace("Source", "Src")}\t"); } return sourcesLabelSb.ToString(); } public IEnumerable<bool[]> FindSolutions() { var elementsMb = sceneObjectsHelper.FindObjectsOfType<IElectricalElementMbEditor>(); elementsConfigurator.Configure(elementsMb); var root = sceneObjectsHelper.FindObjectOfType<FinalDevice>(); if (root == null) { throw new InvalidOperationException("No final device in scene"); } var sourcesMb = sceneObjectsHelper.FindObjectsOfType<SourceMb>().OrderBy(s => s.name); var sources = sourcesMb.Select(mb => (Source) mb.Element).ToArray(); return solver.GetSolutions(root.Element, sources); }
Useful
ContentAsserthelper
ContentTo check that values ​​are set in asses, 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<T>(this T obj, string fieldName = "") { if (obj == null) { if (string.IsNullOrEmpty(fieldName)) { fieldName = $"of type {typeof(T).Name}"; } Debug.LogError($"Field {fieldName} is not installed"); } } public static string AssertNOTEmpty(this string str, string fieldName = "") { if (string.IsNullOrWhiteSpace(str)) { Debug.LogError($"Field {fieldName} is not installed"); } return str; } public static string AssertSceneCanBeLoaded(this string name) { if (!Application.CanStreamedLevelBeLoaded(name)) { Debug.LogError($"Scene {name} can't be loaded."); } return name; } }
Checking that the scene has the ability to be loaded may sometimes not pass, 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);
SceneObjectsHelper
ContentThe SceneObjectsHelper class was also useful for working with scene elements:Lot of code namespace Circuit.Game.Utility { public interface ISceneObjectsHelper { T[] FindObjectsOfType<T>(bool includeDisabled = false) where T : class; T FindObjectOfType<T>(bool includeDisabled = false) where T : class; T Instantiate<T>(T prefab) where T : Object; void DestroyObjectsOfType<T>(bool includeDisabled = false, bool immediate = false) where T : class; void Destroy<T>(T obj, bool immediate = false) where T : Object; void DestroyAllChildren(Transform transform); void Inject(object obj); T GetComponent<T>(GameObject obj) where T : class; } public class SceneObjectsHelper : ISceneObjectsHelper { private readonly DiContainer diContainer; public SceneObjectsHelper(DiContainer diContainer) { this.diContainer = diContainer; } public T GetComponent<T>(GameObject obj) where T : class { return obj.GetComponents<Component>().OfType<T>().FirstOrDefault(); } public T[] FindObjectsOfType<T>(bool includeDisabled = false) where T : class { if (includeDisabled) { return Resources.FindObjectsOfTypeAll(typeof(Object)).OfType<T>().ToArray(); } return Object.FindObjectsOfType<Component>().OfType<T>().ToArray(); } public void DestroyObjectsOfType<T>(bool includeDisabled = false, bool immediate = false) where T : class { var objects = includeDisabled ? Resources.FindObjectsOfTypeAll(typeof(Object)).OfType<T>().ToArray() : Object.FindObjectsOfType<Component>().OfType<T>().ToArray(); foreach (var item in objects) { if (immediate) { Object.DestroyImmediate((item as Component)?.gameObject); } else { Object.Destroy((item as Component)?.gameObject); } } } public void Destroy<T>(T obj, bool immediate = false) where T : Object { if (immediate) { Object.DestroyImmediate(obj); } else { Object.Destroy(obj); } } public void DestroyAllChildren(Transform transform) { int childCount = transform.childCount; for (int i = 0; i < childCount; i++) { Destroy(transform.GetChild(i).gameObject); } } public T FindObjectOfType<T>(bool includeDisabled = false) where T : class { if (includeDisabled) { return Resources.FindObjectsOfTypeAll(typeof(Object)).OfType<T>().FirstOrDefault(); } return Object.FindObjectsOfType<Component>().OfType<T>().FirstOrDefault(); } public void Inject(object obj) { diContainer.Inject(obj); } public T Instantiate<T>(T prefab) where T : Object { var obj = Object.Instantiate(prefab); if (obj is Component) { var components = ((Component) (object) obj).gameObject.GetComponents<Component>(); foreach (var component in components) { Inject(component); } } else { Inject(obj); } return obj; } } }
Here some things may not be very effective where you need high performance, but they rarely cause me and do not create any influence. But they allow you to find objects by interface, for example, which looks pretty nice.CoroutineStarter
ContentOnly MonoBehaviour can launch Coroutine. Because I created the class CoroutineStarter 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 allowed to facilitate automated testing. For example the execution of korutiny in tests: coroutineStarter.When(x => x.BeginCoroutine(Arg.Any<IEnumerator>())).Do(info => { var a = (IEnumerator) info[0]; while (a.MoveNext()) { } });
Gizmo
ContentFor the convenience of displaying invisible elements, I recommend using gizmo images that are visible only in the scene. They make it easy to select an invisible element by clicking. Also made the connection elements in the form of lines: private void OnDrawGizmos() { if (outputConnectorMb != null) { Handles.DrawLine(transform.position, outputConnectorMb.Transform.position); } }

Testing
ContentI wanted to squeeze the most out of automatic testing, because tests were used wherever possible and easy to use.For Unit-tests, it is common practice to use mock-objects instead of the classes implementing the interface on which the test-class depends. For this, I used the NSubstitute library . Than very satisfied.Unity does not support NuGet, so I had to separately get the DLL, then the assembly, how the dependency is added to the AssemblyDefinition file and is used without any problems.
For automated testing, Unity offers TestRunner, which works with the very popular NUnit test framework . From the point of view of TestRunner tests are of two types:- EditMode — , . Nunit . , . GameObject Monobehaviour . , EditMode .
- PlayMode — .
EditMode. In my experience, there was a lot of inconvenience and strange behavior in this mode. Nevertheless, they are convenient to automatically check the performance of the application as a whole. Also provide an honest check 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 some time or a certain number of frames. For this, tests should be described in a similar way to Coroutine. The return value must 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 alsoUnitySetUp 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 allow you to more easily prepare the environment for the class under test and errors, as they pass, allow you to more accurately localize the problem.In unit tests, I test many Core classes and classes needed directly in the game.Tests of the elements of the scheme are very similar, so I created a base class public class ElectricalElementTestsBase<TElement> where TElement : ElectricalElementBase, IElectricalElement, new() { protected TElement element; protected IInputConnector mInput1; protected IInputConnector mInput2; protected IInputConnector mInput3; protected IInputConnector mInput4; [OneTimeSetUp] public void Setup() { element = new TElement(); mInput1 = Substitute.For<IInputConnector>(); mInput2 = Substitute.For<IInputConnector>(); mInput3 = Substitute.For<IInputConnector>(); mInput4 = Substitute.For<IInputConnector>(); } protected void GetValue_3Input(bool input1, bool input2, bool input3, bool expectedOutput) {
Further tests of the element look like this: public class AndTests : ElectricalElementTestsBase<And> { [TestCase(false, false, false)] [TestCase(false, true, false)] [TestCase(true, false, false)] [TestCase(true, true, true)] public new void GetValue_2Input(bool input1, bool input2, bool output) { base.GetValue_2Input(input1, input2, output); } [TestCase(false, false)] [TestCase(true, true)] public new void GetValue_1Input(bool input, bool expectedOutput) { base.GetValue_1Input(input, expectedOutput); } }
Perhaps from the point of view of ease of understanding, this is a complication, which is usually not necessary in tests, but I didn’t want to copy-paste the same thing 11 times.There are also tests GameManager-s. Since they have a lot in common, they also received a base class of tests. Managers of the game in both modes should have some of the same functionality and some different. Common things are tested by the same tests for each successor and in addition specific behavior is tested. Despite the event-based approach, testing the behavior performed by the event was not difficult: [Test] public void FullHelpAgree_FinishesLevel() {
In integration tests, I also tested classes for the editor, and took them from the static context of the DI container. Thereby 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<IElectricalElementMb>(immediate: true); } [OneTimeSetUp] public void Setup() { var container = StaticContext.Container; container.Inject(this); } [TestCase(1, 2)] [TestCase(2, 2)] [TestCase(3, 4)] public void PlaceSolve_And_NoModifications_AllVariantsSolved(int lines, int elementsInLine) { var variants = circuitGenerator.Generate(lines, elementsInLine, new List<int> {0}, false); foreach (var variant in variants) { circuitEditorPlacer.PlaceCircuit(variant); var solutions = solver.FindSolutions(); CollectionAssert.IsNOTEmpty(solutions); } } [TestCase(1, 2, StructureModification.Branching)] [TestCase(1, 2, StructureModification.ThroughLayer)] [TestCase(1, 2, StructureModification.All)] [TestCase(2, 2, StructureModification.Branching)] [TestCase(2, 2, StructureModification.ThroughLayer)] [TestCase(2, 2, StructureModification.All)] public void PlaceSolve_And_Modifications_AllVariantsSolved(int lines, int elementsInLine, StructureModification modification) { var variants = circuitGenerator.Generate(lines, elementsInLine, new List<int> {0}, false, modification); foreach (var variant in variants) { circuitEditorPlacer.PlaceCircuit(variant); var solutions = solver.FindSolutions(); CollectionAssert.IsNOTEmpty(solutions); } }
In this test, real implementations of all dependencies are used and it also sets objects on the stage, which is quite possible in the EditMode tests. It is true to test that it placed them in a sense of responsibility - I have little idea how, so I check that the posted scheme has solutions.PlayMode tests are used as system tests. They are checked prefabs, injection, etc. A good option to use ready-made scenes in which the test only loads and produces some interaction. But I use a prepared empty stage for testing, in which the environment is different from what is in the game. There was an attempt to use PlayMode to test the whole process of the game, like entering the menu, entering the level and so on, but the work of these tests was unstable, so it was decided to postpone it for later (never).For writing tests, it is convenient to use coverage assessment tools, but unfortunately I haven’t found solutions that work with Unity.Found a problem that with the upgrade of Unity to 2018.3 tests began to work much slower, up to 10 times slower (on a synthetic example). The project contains 288 EditMode tests that run for 11 seconds, although nothing has been done there for so long.Development results
Content
Game Level Screenshot Thelogic of some games can be formulated independently of the platform. This at an early stage provides ease of development and testability autotests.DI is convenient. Even taking into account the fact that Unity does not have it natively, the bolted side works very well.Unity allows you to automatically test a project. True, since all embedded GameObject components do not have interfaces and can only be used directly to mock such things as Collider, SpriteRenderer, MeshRenderer, etc. will not work. Although GetComponent allows you to get components by interface. As an option, write your wrappers for everything.The use of autotests simplified the process of forming the initial logic, while there was no user interface to the code. Tests found an error several times at once during development. Naturally, errors appeared further, but often it was possible to write additional tests to this error / modify existing ones and subsequently to catch it automatically. Errors with DI, prefabs, scriptable objects and similar tests are difficult to catch, but it is possible, since you can use real installers for Zenject, which will tighten dependencies, as it happens in the build.Unity generates a huge number of errors, crashes. Often errors are solved by restarting the editor. Faced with the strange loss of references to objects in prefabs. Sometimes the prefab by reference became destroyed (ToString () returns “null”) although everything looks working, the prefab is dragged to the scene and the link is not empty. Sometimes some connections are lost in all scenes. Everything seemed 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 six months passed from the idea to publication on Google Play. The development itself took 3 months, in free time from the main work.