Make it True: desarrollo de un juego de lógica en Unity



Quiero compartir el proceso de desarrollo de un juego móvil simple por dos desarrolladores y un artista. Este artículo es en gran parte una descripción de la implementación técnica.
¡Atención, mucho texto!

El artículo no es una guía o lección, aunque espero que los lectores puedan aprender algo útil de él. Diseñado para desarrolladores familiarizados con Unity con cierta experiencia en programación.

Contenido:


Idea
Jugabilidad
Parcela
Desarrollo
Núcleo
  1. Elementos electricos
  2. Solucionador
  3. ElementsProvider
  4. CircuitGenerator

Clases de juego

  1. Enfoque de desarrollo y DI
  2. Configuracion
  3. Elementos electricos
  4. Gestión del juego
  5. Carga de nivel
  6. Escenas de corte
  7. Jugabilidad adicional
  8. Monetización
  9. Interfaz de usuario
  10. Analítica
  11. Posicionamiento de cámara y diagramas
  12. Esquemas de color

Extensiones de editor


  1. Generador
  2. Solucionador

Útil

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

Prueba
Resumen de desarrollo

Idea


Contenido

Hubo una idea para hacer un juego móvil simple en un período corto.

Términos:

  • Juego fácil de implementar
  • Requisitos mínimos de arte
  • Tiempo de desarrollo corto (varios meses)
  • Con fácil automatización de la creación de contenido (niveles, ubicaciones, elementos del juego)
  • Crea rápidamente un nivel si el juego consta de un número finito de niveles

Para decidir, pero ¿qué hacer realmente? Después de todo, surgió la idea de hacer un juego, no la idea de un juego. Se decidió buscar inspiración en la tienda de aplicaciones.

A los elementos anteriores se agregan:

  • El juego debe tener cierta popularidad entre los jugadores (número de descargas + calificaciones)
  • La tienda de aplicaciones no debe estar llena de juegos similares

Se encontró un juego con un juego basado en puertas lógicas. No hubo similares en gran número. El juego tiene muchas descargas y calificaciones positivas. Sin embargo, después de intentarlo, hubo algunos inconvenientes que se pueden tener en cuenta en su juego.

La jugabilidad del juego es que el nivel es un circuito digital con muchas entradas y salidas. El jugador debe elegir una combinación de entradas para que la salida sea lógica 1. No suena muy difícil. El juego también ha generado niveles automáticamente, lo que sugiere la capacidad de automatizar la creación de niveles, aunque no suena muy simple. El juego también es bueno para aprender, lo que realmente me gustó.

Pros:

  • Simplicidad técnica de juego
  • Parece fácil de probar con pruebas automáticas
  • Capacidad para generar niveles automáticamente

Contras:

  • Primero debes crear niveles

Ahora explore los defectos del juego que inspiraron.

  • No adaptado a la relación de aspecto personalizada, como 18: 9
  • No hay forma de saltear un nivel difícil u obtener una pista
  • En las revisiones hubo quejas sobre un pequeño número de niveles
  • Las críticas se quejaron de la falta de variedad de elementos.

Procedemos a la planificación de nuestro juego:

  • Utilizamos puertas lógicas estándar (AND, NAND, OR, NOR, XOR, XNOR, NOR, NOT)
  • Las puertas se muestran con una imagen en lugar de una designación de texto, que es más fácil de distinguir. Como los elementos tienen notación ANSI estándar, los usamos.
  • Descartamos el interruptor que conecta una entrada a una de las salidas. Debido al hecho de que requiere que hagas clic en ti mismo y no encaja un poco en los elementos digitales reales. Sí, y es difícil imaginar un interruptor de palanca en un chip.
  • Agregue los elementos del codificador y decodificador.
  • Introducimos un modo en el que el jugador debe seleccionar el elemento deseado en la celda con valores fijos en las entradas del circuito.
  • Brindamos ayuda al jugador: pista + nivel de omisión.
  • Sería bueno agregar alguna trama.

Jugabilidad


Contenido

Modo 1: el jugador recibe un circuito y tiene acceso para cambiar los valores en las entradas.
Modo 2: el jugador recibe un circuito en el que puede cambiar los elementos pero no puede cambiar los valores en las entradas.

La jugabilidad será en forma de niveles preparados previamente. Después de completar el nivel, el jugador debe obtener algún resultado, que se hará en forma de las tres estrellas tradicionales, dependiendo del resultado del pasaje.

Cuáles pueden ser los indicadores de rendimiento:
Número de acciones: cada interacción con los elementos del juego aumenta el contador.
El número de diferencias en el estado resultante del original. No tiene en cuenta cuántos intentos tuvo que completar el jugador. Desafortunadamente, no encaja con el segundo régimen.
Sería bueno agregar el mismo modo con generación de nivel aleatorio. Pero por ahora, posponga para más tarde.

Parcela


Contenido

Mientras pensaba en la jugabilidad y comenzaba el desarrollo, aparecieron varias ideas para mejorar el juego. Y apareció una idea bastante interesante: agregar una trama.

Se trata de un ingeniero que diseña circuitos. No está mal, pero no está completo. ¿Quizás vale la pena mostrar la fabricación de fichas en función de lo que hace el jugador? De alguna manera rutinaria, no hay un resultado comprensible y simple.

La idea! Un ingeniero desarrolla un robot genial usando sus circuitos lógicos. El robot es algo bastante simple y comprensible y se adapta perfectamente a la jugabilidad.

¿Recuerdas el primer párrafo, "Requisitos mínimos para el arte"? Algo no encaja con las escenas en la trama. Luego, un artista familiar viene al rescate, que aceptó ayudarnos.

Ahora decidamos el formato y la integración de las escenas en el juego.

La trama debe mostrarse como escenas sin puntuación o una descripción de texto que eliminará los problemas de localización, simplificará su comprensión y muchos reproducirán en dispositivos móviles sin sonido. El juego es un elemento muy real de los circuitos digitales, es decir, es muy posible conectar esto con la realidad.

Las escenas y niveles deberían ser escenas separadas. Antes de cierto nivel, se carga una escena específica.

Bueno, la tarea está establecida, hay recursos que cumplir, el trabajo ha comenzado a hervir.

Desarrollo


Contenido

Inmediatamente me decidí por la plataforma, esta es la Unidad. Sí, un poco exagerado, pero no obstante la conozco.

Durante el desarrollo, el código se escribe inmediatamente con pruebas o incluso después. Pero para una narrativa holística, las pruebas se colocan en una sección separada a continuación. La sección actual describirá el proceso de desarrollo por separado de las pruebas.

Núcleo


Contenido

El núcleo del juego parece bastante simple y no está vinculado al motor, por lo que comenzamos con el diseño en forma de código C #. Parece que puede seleccionar una lógica de núcleo central separada. Llévelo a un proyecto separado.

Unity funciona con una solución C # y los proyectos en el interior son un poco inusuales para un desarrollador regular .Net, los archivos .sln y .csproj son generados por la propia Unity y los cambios dentro de estos archivos no se aceptan para su consideración por parte de Unity. Simplemente los sobrescribirá y eliminará todos los cambios. Para crear un nuevo proyecto, debe usar el archivo de definición de ensamblaje .





Unity ahora genera un proyecto con el nombre apropiado. Todo lo que se encuentra en la carpeta con el archivo .asmdef estará relacionado con este proyecto y ensamblaje.

Elementos electricos


Contenido

La tarea es describir en el código la interacción de elementos lógicos entre sí.

  • Un elemento puede tener múltiples entradas y múltiples salidas.
  • La entrada del elemento debe estar conectada a la salida de otro elemento
  • El elemento en sí debe contener su propia lógica.

Empecemos

  • El elemento contiene su propia lógica de operación y enlaces a sus entradas. Al solicitar un valor de un elemento, toma valores de las entradas, les aplica lógica y devuelve el resultado. Puede haber varias salidas, por lo que se solicita el valor para una salida específica, el valor predeterminado es 0.
  • Para tomar los valores en la entrada, habrá un conector de entrada p, que almacena un enlace a otro: el conector de salida.
  • El conector de salida se refiere a un elemento específico y almacena un enlace a su elemento, cuando solicita un valor, lo solicita al elemento.



Las flechas indican la dirección de los datos, la dependencia de los elementos en la dirección opuesta.
Definir la interfaz del conector. Puedes obtener el valor de ello.

public interface IConnector { bool Value { get; } } 

¿Cómo conectarlo a otro conector?

Definir más interfaces.

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

IInputConnector es un conector de entrada, tiene un enlace a otro conector.

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

El conector de salida se refiere a su elemento desde el cual solicitará un valor.

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

El elemento eléctrico debe contener un método que devuelva un valor en una salida específica, número es el número de la salida.

Lo llamé IElectricalElement, aunque solo transmite niveles lógicos de voltaje, pero por otro lado puede ser un elemento que no agrega lógica en absoluto, solo transmite un valor, como un conductor.

Ahora pasemos a la implementación

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

El conector entrante puede no estar conectado, en cuyo caso devolverá falso.

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

La salida debe tener un enlace a su elemento y su número en relación con el elemento.
Además, usando este número, solicita un valor al elemento.

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

La clase base para todos los elementos, solo contiene una matriz de entradas.

Ejemplo de implementación de un elemento:

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

La implementación se basa completamente en operaciones lógicas sin una tabla de verdad dura. Quizás no sea tan explícito como con la tabla, pero será flexible, funcionará en cualquier cantidad de entradas.
Todas las puertas lógicas tienen una salida, por lo que el valor en la salida no dependerá del número de entrada.

Los elementos invertidos se realizan de la siguiente manera:

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

Vale la pena señalar que aquí el método GetValue se anula y no se anula virtualmente. Esto se hace en base a la lógica de que si Nand guarda en Y, continuará comportándose como Y. También fue posible aplicar la composición, pero esto requeriría un código adicional, lo que no tiene mucho sentido.

Además de las válvulas convencionales, se crearon los siguientes elementos:
Fuente: una fuente de valor constante de 0 o 1.
Conductor: exactamente el mismo O conductor, solo tiene una aplicación ligeramente diferente, ver generación.
AlwaysFalse: siempre devuelve 0, necesario para el segundo modo.

Solucionador


Contenido

Luego, una clase es útil para encontrar automáticamente combinaciones que den 1 en la salida del circuito.

  public interface ISolver { ICollection<bool[]> GetSolutions(IElectricalElement root, params Source[] sources); } public class Solver : ISolver { public ICollection<bool[]> GetSolutions(IElectricalElement root, params Source[] sources) { // max value can be got with this count of bits(sources count), also it's count of combinations -1 // for example 8 bits provide 256 combinations, and max value is 255 int maxValue = Pow(sources.Length); // inputs that can solve circuit var rightInputs = new List<bool[]>(); for (int i = 0; i < maxValue; i++) { var inputs = GetBoolArrayFromInt(i, sources.Length); for (int j = 0; j < sources.Length; j++) { sources[j].Value = inputs[j]; } if (root.GetValue()) { rightInputs.Add(inputs); } } return rightInputs; } private static int Pow(int power) { int x = 2; for (int i = 1; i < power; i++) { x *= 2; } return x; } private static bool[] GetBoolArrayFromInt(int value, int length) { var bitArray = new BitArray(new[] {value}); var boolArray = new bool[length]; for (int i = length - 1; i >= 0; i—) { boolArray[i] = bitArray[i]; } return boolArray; } 

Las soluciones son la fuerza bruta. Para esto, se determina el número máximo que puede expresarse mediante un conjunto de bits en una cantidad igual al número de fuentes. Es decir, 4 fuentes = 4 bits = número máximo 15. Ordenamos todos los números del 0 al 15.

ElementsProvider


Contenido

Por conveniencia de generación, decidí definir un número para cada elemento. Para hacer esto, creé la clase ElementsProvider con la interfaz IElementsProvider.

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

Las dos primeras listas son algo así como las fábricas que dan un artículo en el número especificado. Las dos últimas listas son una muleta que debe usarse debido a las características de Unity. Sobre esto más allá.

CircuitGenerator


Contenido

Ahora la parte más difícil del desarrollo es la generación de circuitos.

La tarea es generar una lista de esquemas a partir de los cuales puede seleccionar el que desee en el editor. La generación es necesaria solo para válvulas simples.

Se establecen ciertos parámetros del esquema, estos son: el número de capas (líneas horizontales de elementos) y el número máximo de elementos en la capa. También es necesario determinar desde qué compuertas necesita generar circuitos.

Mi enfoque consistía en dividir la tarea en dos partes: generación de estructura y selección de opciones.

El generador de estructura determina las posiciones y conexiones de los elementos lógicos.
El generador de variantes selecciona combinaciones válidas de elementos en posiciones.

Structuregener


La estructura consta de capas de elementos lógicos y capas de conductores / inversores. Toda la estructura no contiene elementos reales sino contenedores para ellos.

El contenedor es una clase heredada de IElectricalElement, que contiene una lista de elementos válidos y puede cambiar entre ellos. Cada artículo tiene su propio número en la lista.

 ElectricalElementContainer : ElectricalElementBase, IElectricalElement 


Un contenedor puede establecerse "en sí mismo" en uno de los elementos de la lista. Durante la inicialización, debe proporcionarle una lista de delegados que crearán los elementos. En el interior, llama a cada delegado y obtiene el artículo. Luego puede establecer el tipo específico de este elemento, esto conecta el elemento interno a las mismas entradas que en el contenedor y la salida del contenedor se tomará de la salida de este elemento.



Método para configurar la lista de elementos:

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

A continuación, puede establecer el tipo de esta manera:

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

Después de lo cual funcionará como el elemento especificado.

Se creó la siguiente estructura para el circuito:

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

Los diccionarios aquí almacenan el número de capa en la clave y una matriz de contenedores para esta capa. El siguiente es un conjunto de fuentes y un Dispositivo Final al que todo está conectado.

Así, el generador estructural crea contenedores y los conecta entre sí. Todo esto se crea en capas, de abajo hacia arriba. El fondo es el más ancho (la mayoría de los elementos). La capa de arriba contiene dos veces menos elementos y así sucesivamente hasta llegar a un mínimo. Las salidas de todos los elementos de la capa superior están conectadas al dispositivo final.

La capa del elemento lógico contiene contenedores para puertas. En la capa de conductores hay elementos con una entrada y salida. Los elementos pueden ser un conductor o un elemento NO. El conductor pasa a la salida lo que llegó a la entrada, y el elemento NO devuelve el valor invertido en la salida.

El primero en crear una variedad de fuentes. La generación se produce de abajo hacia arriba, la capa de conductores se genera primero, luego la capa de lógica y, a su salida, nuevamente conductores.



¡Pero tales esquemas son muy aburridos! Queríamos simplificar aún más nuestra vida y decidimos hacer que las estructuras generadas fueran más interesantes (complejas). Se decidió agregar modificaciones de estructura con ramificación o conexión a través de muchas capas.

Bueno, decir "simplificado", esto significa complicarte la vida en otra cosa.
Generar circuitos con el máximo nivel de modificabilidad resultó ser una tarea laboriosa y poco práctica. Por lo tanto, nuestro equipo decidió hacer lo que cumplía con estos criterios:
El desarrollo de esta tarea no llevó mucho tiempo.
Generación más o menos adecuada de estructuras modificadas.
No hubo intersecciones entre los conductores.
Como resultado de una programación larga y difícil, la solución se escribió a las 4 pm.
Echemos un vistazo al código y ̶̶̶̶̶̶̶̶̶̶.

Aquí se encuentra la clase OverflowArray. Por razones históricas, se agregó después de la generación estructural básica y tiene más que ver con la generación de variantes, por lo tanto, se encuentra a continuación. Enlace

 public IEnumerable<CircuitStructure> GenerateStructure(int lines, int maxElementsInLine, StructureModification modification) { var baseStructure = GenerateStructure(lines, maxElementsInLine); for (int i = 0; i < lines; i++) { int maxValue = 1; int branchingSign = 1; if (modification == StructureModification.All) { maxValue = 2; branchingSign = 2; } int lengthOverflowArray = baseStructure.Gates[(i * 2) + 1].Length; var elementArray = new OverflowArray(lengthOverflowArray, maxValue); double numberOfOption = Math.Pow(2, lengthOverflowArray); for (int k = 1; k < numberOfOption - 1; k++) { elementArray.Increase(); if (modification == StructureModification.Branching || modification == StructureModification.All) { if (!CheckOverflowArrayForAllConnection(elementArray, branchingSign, lengthOverflowArray)) { continue; } } // Clone CircuitStructure var structure = (CircuitStructure) baseStructure.Clone(); ConfigureInputs(lines, structure.Conductors, structure.Gates); var sources = AddSourcesLayer(structure.Conductors, maxElementsInLine); var finalElement = AddFinalElement(structure.Conductors); structure.Sources = sources; structure.FinalDevice = finalElement; int key = (i * 2) + 1; ModifyStructure(structure, elementArray, key, modification); ClearStructure(structure); yield return structure; } } } 

Después de ver este código, me gustaría entender qué está sucediendo en él.
No te preocupes! Una breve explicación sin detalles te apura.

Lo primero que hacemos es crear una estructura ordinaria (base).

 var baseStructure = GenerateStructure(lines, maxElementsInLine); 

Luego, como resultado de una simple verificación, establecemos el signo de ramificación (branchingSign) en el valor apropiado. ¿Por qué es esto necesario? Además será claro.

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

Ahora determinamos la longitud de nuestro OverflowArray e inicializamos.

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

Para que podamos continuar nuestras manipulaciones con la estructura, necesitamos encontrar el número de posibles variaciones de nuestro OverflowArray. Para hacer esto, hay una fórmula que se aplicó en la siguiente línea.

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

El siguiente es un bucle anidado en el que se lleva a cabo toda la "magia" y para el cual había todo este prefacio. Al principio, aumentamos los valores de nuestra matriz.

 elementArray.Increase(); 

Después de eso, vemos una verificación de validación, como resultado de lo cual vamos más allá o la próxima iteración.

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

Si la matriz pasó la verificación de validación, entonces clonamos nuestra estructura base. La clonación es necesaria ya que modificaremos nuestra estructura para muchas más iteraciones.

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

Y finalmente, comenzamos a modificar la estructura y limpiarla de elementos innecesarios. Se volvieron innecesarios como resultado de la modificación estructural.

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

No veo el punto con más detalle para analizar docenas de pequeñas funciones que se realizan "en algún lugar" en las profundidades.

Generador de variantes


La estructura + elementos que deberían estar en ella se llaman CircuitVariant.

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

El primer campo es un enlace a la estructura. Los segundos dos diccionarios en los que la clave es el número de la capa, y el valor es una matriz que contiene el número de elementos en sus lugares en la estructura.

Procedemos a la selección de combinaciones. Podemos tener un cierto número de elementos lógicos y conductores válidos. En total, puede haber 6 elementos lógicos y 2 conductores.
Puede imaginar un sistema de números con una base de 6 y obtener en cada categoría los números que corresponden a los elementos. Por lo tanto, al aumentar este número hexadecimal, puede pasar por todas las combinaciones de elementos.

Es decir, un número hexadecimal de tres dígitos será de 3 elementos. Solo vale la pena considerar que el número de elementos no 6 sino 4 puede transmitirse.

Para descargar tal número, determiné la estructura


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


La siguiente es una clase con el extraño nombre OverflowArray . Su esencia es que almacena la matriz ClampedInt y aumenta el orden superior en caso de que ocurra un desbordamiento en el orden bajo y así sucesivamente hasta que alcance el valor máximo en todas las celdas.

De acuerdo con cada ClampedInt, se establecen los valores del ElectricalElementContainer correspondiente. Por lo tanto, es posible ordenar todas las combinaciones posibles. Vale la pena señalar que si desea generar un esquema con elementos (por ejemplo, And (0) y Xor (4)), no necesita ordenar todas las opciones, incluidos los elementos 1,2,3. Para esto, durante la generación, los elementos obtienen sus números locales (por ejemplo, And = 0, Xor = 1), y luego se vuelven a convertir en números globales.

Por lo tanto, puede iterar sobre todas las combinaciones posibles en todos los elementos.

Después de establecer los valores en los contenedores, el circuito se verifica para encontrar soluciones con Solver . Si el circuito pasa la decisión, vuelve.

Después de que se genera el circuito, se verifica el número de soluciones. No debe exceder el límite y no debe tener decisiones que consisten enteramente en 0 o 1.

Mucho código
  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; } } 


Cada uno de los generadores devuelve una variante utilizando la declaración de rendimiento. Por lo tanto, CircuitGenerator usando StructureGenerator y VariantsGenerator genera IEnumerable. (El enfoque con rendimiento ayudó mucho en el futuro, ver más abajo)

Siguiendo del hecho de que el generador de opciones recibe la lista de estructuras. Puede generar opciones para cada estructura de forma independiente. Esto podría ser paralelo, pero agregar AsParallel no funcionó (probablemente el rendimiento interfiere). Paralelizar manualmente será mucho tiempo, porque descartamos esta opción. De hecho, intenté hacer una generación paralela, funcionó, pero hubo algunas dificultades, porque no fue al repositorio.

Clases de juego


Enfoque de desarrollo y DI


Contenido

El proyecto se construye bajo inyección de dependencia (DI). Esto significa que las clases simplemente pueden requerir algún tipo de objeto correspondiente a la interfaz y no participar en la creación de este objeto. ¿Cuáles son los beneficios?

  • El lugar de creación e inicialización del objeto de dependencia se define en un lugar y se separa de la lógica de las clases dependientes, lo que elimina la duplicación de código.
  • Elimina la necesidad de desenterrar todo el árbol de dependencias e instanciar todas las dependencias.
  • Le permite cambiar fácilmente la implementación de la interfaz, que se utiliza en muchos lugares.

Como contenedor DI en el proyecto, se utiliza Zenject .

Zenject tiene varios contextos, solo uso dos de ellos:

  • Contexto del proyecto: registro de dependencias dentro de toda la aplicación.
  • Contexto de la escena: el registro de clases que existen solo en una escena particular y su vida está limitada por el tiempo de vida de la escena.
  • Un contexto estático es un contexto general para todo en general, la peculiaridad es que existe en el editor. Yo uso para inyección en el editor

El registro de la clase se almacena en el instalador . Uso ScriptableObjectInstaller para el contexto del proyecto y MonoInstaller para el contexto de la escena.

La mayoría de las clases con las que me registro en AsSingle, ya que no contienen estado, son más probablemente contenedores de métodos. Uso AsTransient para clases donde hay un estado interno que no debería ser común a otras clases.

Después de eso, debe crear de alguna manera las clases MonoBehaviour que representarán estos elementos. También asigné clases relacionadas con Unity a un proyecto separado dependiendo del proyecto Core.



Para las clases MonoBehaviour, prefiero crear mis propias interfaces. Esto, además de las ventajas estándar de las interfaces, le permite ocultar una gran cantidad de miembros de MonoBehaviour.

Por conveniencia, DI a menudo crea una clase simple que ejecuta toda la lógica y el envoltorio MonoBehaviour para ello. Por ejemplo, la clase tiene métodos de Inicio y Actualización, creo tales métodos en la clase, luego en la clase MonoBehaviour agrego un campo de dependencia y en los métodos correspondientes llamo Inicio y Actualización. Esto le da la inyección "correcta" al constructor, el desprendimiento de la clase principal del contenedor DI y la capacidad de probar fácilmente.

Configuracion


Contenido

Por configuración, me refiero a los datos comunes a toda la aplicación. En mi caso, estos son prefabricados, identificadores para publicidad y compras, etiquetas, nombres de escenas, etc. Para estos fines, uso ScriptableObjects:

  1. Para cada grupo de datos, se asigna una clase descendiente ScriptableObject.
  2. Crea los campos serializables necesarios.
  3. Se agregan las propiedades de lectura de estos campos.
  4. La interfaz con los campos anteriores está resaltada
  5. Una clase se registra en una interfaz en un contenedor DI
  6. Ganancia

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

Para la configuración, un instalador separado (código abreviado):

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

Elementos electricos


Contenido

Ahora necesitas imaginar de alguna manera los elementos eléctricos

 public interface IElectricalElementMb { GameObject GameObject { get; } string Name { get; set; } IElectricalElement Element { get; set; } IOutputConnectorMb[] OutputConnectorsMb { get; } IInputConnectorMb[] InputConnectorsMb { get; } Transform Transform { get; } void SetInputConnectorsMb(InputConnectorMb[] inputConnectorsMb); void SetOutputConnectorsMb(OutputConnectorMb[] outputConnectorsMb); } [DisallowMultipleComponent] public class ElectricalElementMb : MonoBehaviour, IElectricalElementMb { [SerializeField] private OutputConnectorMb[] outputConnectorsMb; [SerializeField] private InputConnectorMb[] inputConnectorsMb; public Transform Transform => transform; public GameObject GameObject => gameObject; public string Name { get => name; set => name = value; } public virtual IElectricalElement Element { get; set; } public IOutputConnectorMb[] OutputConnectorsMb => outputConnectorsMb; public IInputConnectorMb[] InputConnectorsMb => inputConnectorsMb; } 

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

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

  public class InputConnectorMb : MonoBehaviour, IInputConnectorMb { [SerializeField] private OutputConnectorMb outputConnectorMb; public Transform Transform => transform; public IOutputConnectorMb OutputConnectorMb { get => outputConnectorMb; set => outputConnectorMb = (OutputConnectorMb) value; } public IInputConnector InputConnector { get; } = new InputConnector(); #if UNITY_EDITOR private void OnDrawGizmos() { if (outputConnectorMb != null) { Handles.DrawLine(transform.position, outputConnectorMb.Transform.position); } } #endif } 

Tenemos la línea public IElectricalElement Element {get; conjunto }

¿Solo aquí está cómo instalar este artículo?
Una buena opción sería hacer genéricos:
public class ElectricalElementMb: MonoBehaviour, IElectricalElementMb donde T: IElectricalElement
Pero el problema es que Unity no admite genéricos en las clases MonoBehavior. Además, Unity no admite la serialización de propiedades e interfaces.

Sin embargo, en tiempo de ejecución es bastante posible pasar IElectricalElement Element {get; conjunto }
valor deseado

Hice enum ElectricalElementType en el que habrá todos los tipos necesarios. Enum está bien serializado por Unity y se muestra muy bien en el Inspector como una lista desplegable. Se definieron dos tipos de elementos: los que se crean en tiempo de ejecución y los que se crean en el editor y se pueden guardar. Por lo tanto, hay IElectricalElementMb e IElectricalElementMbEditor, que además contiene un campo de tipo ElectricalElementType.

El segundo tipo también debe inicializarse en tiempo de ejecución. Para hacer esto, hay una clase que al principio omitirá todos los elementos y los inicializará según el tipo en el campo enum. Como sigue:

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

Gestión del juego


Contenido

A continuación, surge la pregunta, ¿dónde colocar la lógica del juego en sí (verificar las condiciones del pasaje, contar las lecturas del pasaje y ayudar al jugador)? También hay preguntas sobre la ubicación de la lógica para guardar y cargar el progreso, la configuración y otras cosas.

Para esto, distingo ciertas clases de gerentes que son responsables de una determinada clase de tareas.

DataManager es responsable de almacenar datos de los resultados de pasar la configuración del usuario y del juego. Está registrado por AsSingle en el contexto del proyecto. Esto significa que él es uno para toda la aplicación. Mientras se ejecuta la aplicación, los datos se almacenan directamente en la memoria, dentro del DataManager.
Utiliza el IFileStoreService , que es responsable de cargar y guardar datos e IFileSerializerresponsable de serializar archivos en un formato listo para guardar.

LevelGameManager es un administrador de juegos en una sola escena.
Obtuve un poco de GodObject, porque todavía es responsable de la interfaz de usuario, es decir, abrir y cerrar el menú, reacción a los botones. Pero es aceptable, dado el tamaño del proyecto y la falta de la necesidad de expandirlo, por lo que una secuencia de acciones aún más fácil y más visible.

Hay dos opciones Esto es lo que se llama LevelGameManager1 y LevelGameManager2 para los modos 1 y 2, respectivamente.

En el primer caso, la lógica se basa en la reacción al evento de un cambio en el valor en una de las Fuentes y en la verificación del valor en la salida del circuito.

En el segundo caso, la lógica responde a un evento de cambio de elemento y también verifica los valores en la salida del circuito.

Existe información de nivel actual, como el número de nivel y la asistencia al jugador.

Los datos sobre el nivel actual se almacenan en CurrentLevelData . Allí se almacena un número de nivel: una propiedad booleana con un cheque de ayuda, una bandera de oferta para evaluar el juego y datos para ayudar al jugador.

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

La ayuda para el primer modo son los números de origen y los valores en ellos. En el segundo modo, este es el tipo de elemento que debe establecerse en la celda.

La colección contiene estructuras que almacenan la posición y el valor que deben establecerse en la posición especificada. Un diccionario sería más bonito, pero Unity no puede serializar diccionarios.

Las diferencias entre las escenas de diferentes modos son que en el contexto de la escena, se establecen otro LevelGameManager y otro ICurrentLevelData .

En general, tengo un enfoque basado en eventos para la comunicación de elementos. Por un lado, es lógico y conveniente. Por otro lado, existe la oportunidad de obtener problemas sin darse de baja cuando sea necesario. Sin embargo, no hubo problemas en este proyecto, y la escala no es demasiado grande. Por lo general, se produce una suscripción durante el inicio de la escena para todo lo que necesita. Casi nada se crea en tiempo de ejecución, por lo que no hay confusión.

Carga de nivel


Contenido

Cada nivel en el juego está representado por una escena de Unity, debe contener un prefijo de nivel y un número, por ejemplo, "Nivel23". El prefijo está incluido en la configuración. La carga del nivel se produce por nombre, que se forma a partir del prefijo. Por lo tanto, la clase LevelsManager puede cargar niveles por número.

Escenas de corte


Los contenidos de la

escena son escenas de unidad ordinarias con números en el título, similares a los niveles.
La animación en sí se implementa utilizando la línea de tiempo. Desafortunadamente, no tengo habilidades de animación ni la capacidad de trabajar con Timeline, así que "no le dispares al pianista, él juega como puede".



La verdad resultó que una escena lógica debería consistir en diferentes escenas con diferentes objetos. Resultó que esto se notó un poco tarde, pero se decidió simplemente: colocando partes de las escenas en el escenario en diferentes lugares y moviendo la cámara al instante.



Jugabilidad adicional


Contenido

El juego se evalúa por el número de acciones por nivel y el uso de pistas. Cuanta menos acción, mejor. El uso de la información sobre herramientas reduce la calificación máxima a 2 estrellas, saltando el nivel a 1 estrella. Para evaluar el pasaje, se almacena el número de pasos para pasar. Se compone de dos valores: el valor mínimo (para 3 estrellas) y el máximo (1 estrella).

El número de pasos para pasar niveles no se almacena en el archivo de escena en sí, sino en el archivo de configuración, ya que debe mostrar el número de estrellas para el nivel pasado. Esto complicó ligeramente el proceso de creación de niveles. Fue especialmente interesante ver cambios en el sistema de control de versiones:



Intenta adivinar a qué nivel pertenece. Era posible almacenar el diccionario, por supuesto, pero en primer lugar, Unity no lo serializaría, en segundo lugar, tendría que establecer manualmente los números.

Si es difícil para el jugador completar el nivel, puede obtener una pista: los valores correctos en algunas entradas o el elemento correcto en el segundo modo. Esto también se hizo manualmente, aunque podría automatizarse.

Si la ayuda del jugador no ayudó, puede omitir completamente el nivel. En caso de perder un nivel, el jugador obtiene 1 estrella por él.

Un usuario que ha pasado un nivel con una pista no puede volver a ejecutarlo por un tiempo, por lo que sería difícil volver a ejecutar el nivel con memoria nueva, como si no tuviera una pista.

Monetización


Contenido

Hay dos tipos de monetización en el juego: mostrar anuncios y deshabilitar anuncios por dinero. Una visualización de anuncios incluye la visualización de anuncios entre niveles y la visualización de anuncios premiados para omitir un nivel.

Si el jugador está dispuesto a pagar por desactivar la publicidad, puede hacerlo. En este caso, no se mostrarán anuncios entre niveles y al omitir un nivel.

Para la publicidad, se creó una clase llamada AdsService , con una interfaz

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

Aquí HelpAd es un anuncio recompensado por saltar un nivel. Inicialmente, llamamos a ayuda parcial y total. Parcial es una pista, y completo es un nivel de omisión.

Esta clase contiene dentro de la limitación de la frecuencia de mostrar anuncios por tiempo, después del primer lanzamiento del juego.

La implementación utiliza Google Mobile Ads Unity Plugin .

Con publicidad recompensada, pisé un rastrillo; resulta que los delegados leales pueden ser llamados en otro hilo, no está muy claro por qué. Por lo tanto, es mejor que esos delegados no llamen a nada en el código relacionado con Unity. Si se realizó una compra para deshabilitar la publicidad, el anuncio no se mostrará y el delegado ejecutará inmediatamente la exhibición exitosa del anuncio.

Hay una interfaz para comprar

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

Unity IAP se utiliza en la implementación

. Hay un truco para comprar desconexiones de anuncios. Google Play no parece proporcionar información de que el jugador compró una compra. Solo llegará la confirmación de que ella pasó una vez. Pero si coloca el estado del producto después de la compra, no Completo sino Pendiente, esto le permitirá verificar la propiedad del producto hasReceipt . Si es cierto, la compra se ha completado.

Aunque, por supuesto, confunde este enfoque, sospecho que puede que no todo sea sencillo.

El método RemoveDisableAd es necesario en el momento de la prueba, elimina la interrupción de la publicidad comprada.

Interfaz de usuario


Contenido

Todos los elementos de la interfaz funcionan de acuerdo con un enfoque orientado a eventos. Los elementos de interfaz en sí mismos generalmente no contienen lógica más que eventos llamados por métodos públicos que Unity puede usar. Aunque también sucede que realiza algunas tareas relacionadas solo con la interfaz.

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

De hecho, este no es siempre el caso. Es bueno dejar estos elementos como Vista activa, hacer que un evento escuche de ella, algo así como un controlador que activará las acciones necesarias en los administradores.

Analítica


Contenido

En el camino de menor resistencia, se eligió el análisis de Unity . Fácil de implementar, aunque limitado para una suscripción gratuita, es imposible exportar los datos de origen. También hay un límite en el número de eventos: 100 / hora por jugador.
Para análisis, creó la clase de contenedor AnalyticsService . Tiene métodos para cada tipo de evento, recibe los parámetros necesarios y hace que el evento se envíe utilizando las herramientas integradas en Unity. Crear un método para cada evento ciertamente no es la mejor práctica en su conjunto, pero en un proyecto a sabiendas pequeño es mejor que hacer algo grande y complicado.
Todos los eventos utilizados son CustomEvent.. Se crean a partir del nombre del evento y el nombre y el valor del parámetro del diccionario. AnalyticsService obtiene los valores requeridos de los parámetros y crea un diccionario dentro.

Todos los nombres y parámetros de eventos se colocan en constantes. No en la forma de un enfoque tradicional con ScriptableObject, ya que estos valores nunca deberían cambiar.

Ejemplo de método:

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

Posicionamiento de cámara y diagramas


Contenido La

tarea es colocar FinalDevice en la parte superior de la pantalla, a la misma distancia del borde superior y las Fuentes desde la parte inferior también siempre a la misma distancia del borde inferior. Además, las pantallas vienen en diferentes relaciones de aspecto, debe ajustar el tamaño de la cámara antes de comenzar el nivel para que se ajuste correctamente al circuito.

Para hacer esto, se crea la clase CameraAlign . Algoritmo de tamaño:

  1. Encuentra todos los elementos necesarios en el escenario
  2. Encuentre el ancho y la altura mínimos en función de la relación de aspecto
  3. Determinar el tamaño de la cámara
  4. Coloca la cámara en el centro
  5. Mueva FinalDevice a la parte superior de la pantalla
  6. Mover fuentes a la parte inferior de la pantalla

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

Este método se llama cuando la escena comienza en la clase contenedora.

Esquemas de color


Contenido

Dado que el juego tendrá una interfaz muy primitiva, decidí hacerlo con dos esquemas de color, blanco y negro.

Para hacer esto, creó una interfaz

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

Los colores se pueden configurar directamente en el editor de Unity; esto se puede utilizar para probar. Luego se pueden cambiar y tener dos juegos de colores.

Los colores de fondo y primer plano pueden cambiar, un acento de color en cualquier modo.

Como el reproductor puede establecer un tema no estándar, los datos de color deben almacenarse en el archivo de configuración. Si el archivo de configuración no contenía datos de color, entonces se rellenan con valores estándar.

Luego hay varias clases: CameraColorAdjustment : responsable de establecer el color de fondo de la cámara, UiColorAdjustment : establecer los colores de los elementos de la interfaz y TextMeshColorAdjustment- establece el color de los números en las fuentes. UiColorAdjustment también usa etiquetas. En el editor, puede marcar cada elemento con una etiqueta que indicará para qué tipo de color se debe establecer (Fondo, Primer plano, AccentColor y FixedColor). Todo esto se establece al comienzo de la escena o por el evento de un cambio de esquema de color.

Resultado:





Extensiones de editor


Contenido

Para simplificar y acelerar el proceso de desarrollo, a menudo es necesario crear la herramienta correcta, que no es proporcionada por las herramientas de edición estándar. El enfoque tradicional en Unity es crear una clase descendiente EditorWindow. También hay un enfoque con UiElements, pero todavía está en desarrollo, por lo que decidí usar el enfoque tradicional.

Si simplemente crea una clase que usa algo del espacio de nombres UnityEditor al lado de otras clases para el juego, entonces el proyecto simplemente no se ensamblará, ya que este espacio de nombres no está disponible en la compilación. Hay varias soluciones:

  • Seleccione un proyecto separado para los scripts del editor.
  • Coloque archivos en la carpeta Activos / Editor
  • Envuelva estos archivos en #if UNITY_EDITOR

El proyecto utiliza el primer enfoque y, a veces, #if UNITY_EDITOR, si es necesario, agrega una pequeña parte para el editor a la clase que se requiere en la compilación.

Todas las clases que solo se necesitan en el editor que definí en el ensamblado, que estarán disponibles solo en el editor. Ella no irá a la construcción del juego.



Sería bueno tener DI en las extensiones de su editor. Para esto utilizo Zenject.StaticContext. Para configurarlo en el editor, se utiliza una clase con el atributo InitializeOnLoad, en el que hay un constructor estático.

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

Para registrar las clases ScriptableObject en un contexto estático, utilizo el siguiente código:

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

La implementación de TI solo es necesaria para esta línea. AssetDatabase.LoadAssetAtPath (ruta)

No es posible agregar una dependencia al constructor. En su lugar, agregue el atributo [Inject] en los campos de dependencia a la clase de ventana y llame a
StaticContext.Container.Inject (this) al inicio de la ventana ;

También recomiendo agregar al ciclo de actualización de la ventana una comprobación nula de uno de los campos dependientes, y si el campo está vacío, realice la línea anterior. Porque después de cambiar el código en el proyecto, Unity puede recrear la ventana y no llamar a Awake en ella.

Generador


Contenido La


vista inicial del generador

La ventana debe proporcionar una interfaz para generar una lista de esquemas con parámetros, mostrar una lista de esquemas y colocar el esquema seleccionado en la escena actual.

La ventana consta de tres secciones de izquierda a derecha:

  • ajustes de generación
  • lista de opciones en forma de botones
  • opción seleccionada como texto

Las columnas se crean con EditorGUILayout.BeginVertical () y EditorGUILayout.EndVertical (). Desafortunadamente, no funcionó arreglar y limitar los tamaños, pero esto no es tan crítico.

Resultó que el proceso de generación en una gran cantidad de circuitos no es tan rápido. Se obtienen muchas combinaciones con los elementos de I. Como mostró el generador de perfiles, la parte más lenta es el circuito en sí. Paralelizarlo no es una opción; todas las opciones usan un esquema, pero es difícil clonar esta estructura.

Entonces pensé que probablemente todo el código de las extensiones del editor funciona en modo de depuración. En Release, la depuración no funciona tan bien, los puntos de interrupción no se detienen, se omiten las líneas, etc. De hecho, habiendo medido el rendimiento, resultó que la velocidad del generador en Unity corresponde al ensamblaje de depuración lanzado desde la aplicación de consola, que es ~ 6 veces más lenta que la versión. Ten esto en cuenta.

Alternativamente, puede hacer un ensamblaje externo y agregar a la DLL de Unity con el ensamblaje, pero esto complica enormemente el ensamblaje y la edición del proyecto.

Inmediatamente llevé el proceso de generación a una Tarea separada con un código que contiene esto:
circuitGenerator.Generate (lines, maxElementsInLine, availableLogicalElements, useNOT, modificación) .ToList ()

Ya mejor, el editor no se bloquea en el momento de la generación. Pero aún es necesario esperar mucho tiempo, durante varios minutos (más de 20 minutos en circuitos de gran tamaño). Además, había un problema que la tarea no se puede completar tan fácilmente y continúa funcionando hasta que se completa la generación.

Mucho código
 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(); } } 


La idea es que se genere el fondo y, previa solicitud, se actualizará la lista interna de opciones ordenadas. Luego puede página por página para seleccionar opciones. Por lo tanto, no es necesario ordenar cada vez, lo que acelera significativamente el trabajo en listas grandes. Los esquemas se ordenan por "interés": por el número de soluciones, por aumento y por cómo se requieren varios valores para la solución. Es decir, un circuito con una solución de 1 1 1 1 es menos interesante que 1 0 1 1.



Por lo tanto, resultó, sin esperar el final de la generación, seleccionar un circuito para el nivel. Otra ventaja es que debido a la paginación, el editor no se ralentiza como el ganado.

La característica de Unity es muy inquietante porque al hacer clic en Reproducir, el contenido de la ventana se restablece, como todos los datos generados. Si fueran fácilmente serializables, podrían almacenarse como archivos. De esta manera, incluso puede almacenar en caché los resultados de la generación. Pero, por desgracia, es difícil serializar una estructura compleja donde los objetos se refieren entre sí.

Además, agregué líneas a cada puerta, como

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

Lo que mejoró enormemente el rendimiento.

Solucionador


Contenido

Cuando ensambla un circuito en el editor, debe ser capaz de comprender rápidamente si se está resolviendo y cuántas soluciones tiene. Para hacer esto, creé una ventana de "solucionador". Proporciona soluciones al esquema actual en forma de texto.



La lógica de su "backend":

 public string GetSourcesLabel() { var sourcesMb = sceneObjectsHelper.FindObjectsOfType<SourceMb>().OrderBy(s => s.name); var sourcesLabelSb = new StringBuilder(); foreach (var item in sourcesMb) { sourcesLabelSb.Append($"{item.name.Replace("Source", "Src")}\t"); } return sourcesLabelSb.ToString(); } public IEnumerable<bool[]> FindSolutions() { var elementsMb = sceneObjectsHelper.FindObjectsOfType<IElectricalElementMbEditor>(); elementsConfigurator.Configure(elementsMb); var root = sceneObjectsHelper.FindObjectOfType<FinalDevice>(); if (root == null) { throw new InvalidOperationException("No final device in scene"); } var sourcesMb = sceneObjectsHelper.FindObjectsOfType<SourceMb>().OrderBy(s => s.name); var sources = sourcesMb.Select(mb => (Source) mb.Element).ToArray(); return solver.GetSolutions(root.Element, sources); } 

Útil


Contenido

Ayudar


Contenido
Para verificar que los valores están establecidos en activos, utilizo métodos de extensión que llamo en 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; } } 

La verificación de que la escena tiene la capacidad de cargarse a veces puede fallar, aunque la escena puede cargarse. Quizás esto sea un error en Unity.

Ejemplos de uso:

 mainMenuSceneName.AssertNOTEmpty(nameof(mainMenuSceneName)).AssertSceneCanBeLoaded(); levelNamePrefix.AssertNOTEmpty(nameof(levelNamePrefix)); editorElementsPrefabs.AssertNOTNull(); not.AssertType(ElectricalElementType.NOT); //     enum     

SceneObjectsHelper


Contenido

Para trabajar con elementos de escena, la clase SceneObjectsHelper también fue útil:

Mucho código
 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; } } } 


Aquí, algunas cosas pueden no ser muy efectivas cuando se necesita un alto rendimiento, pero rara vez se las solicita y no crean ninguna influencia. Pero le permiten encontrar objetos por la interfaz, por ejemplo, que se ve bastante bonita.

Coroutinestarter


Contenidos

Launch Coroutine solo puede MonoBehaviour. Así que creé la clase CoroutineStarter y la registré en el contexto de la escena.

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

Además de la conveniencia, la introducción de tales herramientas facilitó la prueba automática. Por ejemplo, la ejecución de corutina en pruebas:

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

Gizmo


Contenido

Para la conveniencia de mostrar elementos invisibles, recomiendo usar imágenes de artefactos que solo sean visibles en la escena. Facilitan la selección de un elemento invisible con un clic. También se hicieron conexiones de elementos en forma de líneas:

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



Prueba


Contenido

Quería aprovechar al máximo las pruebas automáticas, porque las pruebas se usaban siempre que era posible y eran fáciles de usar.

Para las pruebas unitarias, es habitual usar objetos simulados en lugar de las clases que implementan la interfaz de la que depende la clase de prueba. Para esto, utilicé la biblioteca NSubstitute . Lo que está muy contento.

Unity no es compatible con NuGet, por lo que tuve que obtener la DLL por separado, luego el ensamblado, ya que una dependencia se agrega al archivo AssemblyDefinition y se usa sin problemas.



Para las pruebas automáticas, Unity ofrece TestRunner, que funciona con el popular marco de prueba NUnit . Desde el punto de vista de TestRunner, hay dos tipos de pruebas:

  • EditMode — , . Nunit . , . GameObject Monobehaviour . , EditMode .
  • PlayMode — .
EditMode En mi experiencia, ha habido muchos inconvenientes y comportamientos extraños en este modo. Sin embargo, son convenientes para verificar automáticamente el estado de la aplicación en su conjunto. También proporcionan una verificación honesta del código en métodos como Inicio, Actualización y similares.

Las pruebas de PlayMode pueden describirse como pruebas normales de NUnit, pero existe una alternativa. En PlayMode, es posible que deba esperar un tiempo o una cierta cantidad de fotogramas. Para hacer esto, las pruebas deben describirse de manera similar a Coroutine. El valor devuelto debe ser IEnumerator / IEnumerable y dentro, para omitir el tiempo, debe usar, por ejemplo:

 yield return null; 

o

 yield return new WaitForSeconds(1); 

Hay otros valores de retorno.

Dicha prueba debe establecer el atributo UnityTest . También hay
atributos UnitySetUp y UnityTearDown con los que debe utilizar un enfoque similar.

Yo, a su vez, comparto las pruebas de EditMode para Modular e Integración.

Las pruebas unitarias prueban solo una clase en completo aislamiento de otras clases. Tales pruebas a menudo facilitan la preparación del entorno para la clase probada y los errores, cuando se pasan, le permiten localizar el problema con mayor precisión.

En las pruebas unitarias, pruebo muchas clases principales y clases necesarias directamente en el juego.
Las pruebas de los elementos del circuito son muy similares, así que creé una clase base

 public class ElectricalElementTestsBase<TElement> where TElement : ElectricalElementBase, IElectricalElement, new() { protected TElement element; protected IInputConnector mInput1; protected IInputConnector mInput2; protected IInputConnector mInput3; protected IInputConnector mInput4; [OneTimeSetUp] public void Setup() { element = new TElement(); mInput1 = Substitute.For<IInputConnector>(); mInput2 = Substitute.For<IInputConnector>(); mInput3 = Substitute.For<IInputConnector>(); mInput4 = Substitute.For<IInputConnector>(); } protected void GetValue_3Input(bool input1, bool input2, bool input3, bool expectedOutput) { // arrange mInput1.Value.Returns(input1); mInput2.Value.Returns(input2); mInput3.Value.Returns(input3); element.Input = new[] {mInput1, mInput2, mInput3}; // act bool result = element.GetValue(); // assert Assert.AreEqual(expectedOutput, result); } protected void GetValue_2Input(bool input1, bool input2, bool expectedOutput) { // arrange mInput1.Value.Returns(input1); mInput2.Value.Returns(input2); element.Input = new[] {mInput1, mInput2}; // act bool result = element.GetValue(); // assert Assert.AreEqual(expectedOutput, result); } protected void GetValue_1Input(bool input, bool expectedOutput) { // arrange mInput1.Value.Returns(input); element.Input = new[] {mInput1}; // act bool result = element.GetValue(); // assert Assert.AreEqual(expectedOutput, result); } } 

Las pruebas de elementos adicionales se ven así:

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

Quizás esto sea una complicación en términos de facilidad de comprensión, que generalmente no es necesaria en las pruebas, pero no quería copiar y pegar lo mismo 11 veces.

También hay pruebas de GameManagers. Como tienen mucho en común, también obtuvieron una clase base de pruebas. Los administradores de juegos en ambos modos deben tener una funcionalidad idéntica y otra diferente. Las cosas generales se prueban con las mismas pruebas para cada sucesor y se prueba además el comportamiento específico. A pesar del enfoque del evento, no fue difícil probar el comportamiento realizado por el evento:

 [Test] public void FullHelpAgree_FinishLevel() { // arrange levelGameManager.Start(); helpMenu.ClearReceivedCalls(); dataManager.ClearReceivedCalls(); // act helpMenu.FullHelpClick += Raise.Event<Action>(); fullHelpWindow.Agreed += Raise.Event<Action<bool>>(true); // assert dataManager.Received().SaveGame(); helpMenu.Received().Hide(); } [Test] public void ChangeSource_RootOutBecomeTrue_SavesGameOpensMenu() { // arrange currentLevelData.IsTestLevel.Returns(false); rootOutputMb.OutputConnector.Value.Returns(true); // act levelGameManager.Start(); levelFinishedMenu.ClearReceivedCalls(); dataManager.ClearReceivedCalls(); source.ValueChanged += Raise.Event<Action<bool>>(true); // assert dataManager.Received().SaveGame(); levelFinishedMenu.Received().Show(); } 

En las pruebas de integración, también probé clases para el editor y las tomé del contexto estático del contenedor DI. Por lo tanto, la verificación incluye la inyección correcta, que no es menos importante que la prueba unitaria.

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

Esta prueba utiliza implementaciones reales de todas las dependencias y también establece objetos en el escenario, lo cual es bastante posible en las pruebas de EditMode. Es cierto probar que los puso en su sano juicio: tengo poca idea de cómo hacerlo, así que verifico que el circuito publicado tenga soluciones.

En integración, también hay pruebas para CircuitGenerator (StructureGenerator + VariantsGenerator) y Solver

 public class CircuitGeneratorTests { private ICircuitGenerator circuitGenerator; private ISolver solver; [SetUp] public void Setup() { solver = new Solver(); var gates = new List<Func<IElectricalElement>> { () => new And(), () => new Or(), () => new Xor() }; var conductors = new List<Func<IElectricalElement>> { () => new Conductor(), () => new Not() }; var elements = Substitute.For<IElementsProvider>(); 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<int>(); 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<int, ElectricalElementContainer[]> layers, Type elementType) { AssertLayersContains(layers, elementType, true); } private static void AssertLayersNotContains(IDictionary<int, ElectricalElementContainer[]> layers, Type elementType) { AssertLayersContains(layers, elementType, false); } private static void AssertLayersContains(IDictionary<int, ElectricalElementContainer[]> 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); } } } 


Las pruebas de PlayMode se usan como pruebas del sistema. Verifican prefabricados, inyección, etc. Una buena opción es usar escenas preparadas en las que la prueba solo se carga y produce algunas interacciones. Pero utilizo una escena vacía preparada para las pruebas, en la que el entorno es diferente del que habrá en el juego. Hubo un intento de usar PlayMode para probar todo el proceso del juego, como ingresar al menú, ingresar al nivel, etc., pero el trabajo de estas pruebas resultó ser inestable, por lo que se decidió posponerlo para más tarde (nunca).

Es conveniente usar herramientas de evaluación de cobertura para escribir pruebas, pero desafortunadamente no he encontrado ninguna solución que funcione con Unity.

Encontré un problema que con la actualización de Unity a 2018.3, las pruebas comenzaron a funcionar mucho más lento, hasta 10 veces más lento (en un ejemplo sintético). El proyecto contiene 288 pruebas EditMode que se ejecutan durante 11 segundos, aunque no se ha hecho nada durante tanto tiempo.

Resumen de desarrollo





Captura de pantalla de contenido del nivel del juego La

lógica de algunos juegos se puede formular independientemente de la plataforma. En una etapa temprana, esto proporciona facilidad de desarrollo y capacidad de prueba por medio de pruebas automáticas.

DI es conveniente. Incluso teniendo en cuenta el hecho de que Unity no lo tiene de forma nativa, el atornillado en el lateral funciona de manera bastante tolerable.

Unity le permite probar automáticamente un proyecto. Es cierto, dado que todos los componentes integrados de GameObject no tienen interfaces y solo se pueden usar directamente para burlarse de cosas como Collider, SpriteRenderer, MeshRenderer, etc. No funcionará. Aunque GetComponent le permite obtener componentes en la interfaz. Como opción, escriba sus propios envoltorios para todo.

El uso de pruebas automáticas simplificó el proceso de generar la lógica inicial, mientras que no había una interfaz de usuario para el código. Las pruebas encontraron varias veces un error inmediatamente durante el desarrollo. Naturalmente, los errores aparecieron aún más, pero a menudo fue posible escribir pruebas adicionales / modificar las existentes y luego detectarlas automáticamente. Los errores con DI, prefabricados, objetos programables y similares, pruebas son difíciles de atrapar, pero es posible, ya que puede usar instaladores reales para Zenject, lo que reforzará las dependencias, como sucede en la compilación.

La unidad genera una gran cantidad de errores, fallas. A menudo, los errores se resuelven reiniciando el editor. Frente a una extraña pérdida de referencias a objetos en prefabricados. A veces, el prefab por referencia se destruye (ToString () devuelve "nulo"), aunque todo parece funcionar, el prefab se arrastra a la escena y el enlace no está vacío. A veces se pierden algunas conexiones en todas las escenas. Todo parece estar instalado, funcionó, pero al cambiar a otra rama, todas las escenas se rompen, no hay enlaces entre los elementos.

Afortunadamente, estos errores a menudo se corrigieron reiniciando el editor o algunas veces eliminando la carpeta Biblioteca.

En total, ha pasado aproximadamente medio año desde la idea hasta su publicación en Google Play. El desarrollo en sí tomó alrededor de 3 meses, en tiempo libre del trabajo principal.

Source: https://habr.com/ru/post/453564/


All Articles