
Quero compartilhar o processo de desenvolvimento de um jogo móvel simples por dois desenvolvedores e um artista. Este artigo é basicamente uma descrição da implementação técnica.
Cuidado, muito texto!
O artigo não é um guia ou lição, embora eu espero que os leitores possam aprender algo útil com ele. Projetado para desenvolvedores familiarizados com o Unity com alguma experiência em programação.
Conteúdo:
IdéiaJogabilidadeTraçarDesenvolvimentoCore- Elementos elétricos
- Solver
- ElementsProvider
- CircuitGenerator
Classes de jogos- Abordagem de Desenvolvimento e DI
- Configuração
- Elementos elétricos
- Gerenciamento de jogos
- Carregamento nivelado
- Cutscenes
- Jogabilidade adicional
- Monetização
- Interface do usuário
- Google Analytics
- Diagramas e posicionamento da câmera
- Esquemas de cores
Extensões do editor
- Gerador
- Solver
Útil- Asserthelp
- SceneObjectsHelper
- Coroutinestarter
- Gizmo
TesteResumo do DesenvolvimentoIdéia
ConteúdoHavia uma idéia para fazer um jogo móvel simples em um curto período.
Termos:
- Jogo fácil de implementar
- Requisitos mínimos de arte
- Curto tempo de desenvolvimento (vários meses)
- Com fácil automação da criação de conteúdo (níveis, locais, elementos do jogo)
- Crie rapidamente um nível se o jogo consistir em um número finito de níveis
Para decidir, mas o que realmente fazer? Afinal, surgiu a ideia de criar um jogo, não a ideia de um jogo. Foi decidido buscar inspiração na loja de aplicativos.
Para os itens acima são adicionados:
- O jogo deve ter uma certa popularidade entre os jogadores (número de downloads + classificações)
- A loja de aplicativos não deve estar cheia de jogos semelhantes
Um jogo foi encontrado com uma jogabilidade baseada em portas lógicas. Não houve números semelhantes em grandes números.O jogo tem muitos downloads e classificações positivas. No entanto, tendo tentado, houve algumas desvantagens que podem ser levadas em consideração no seu jogo.
A jogabilidade do jogo é que o nível é um circuito digital com muitas entradas e saídas. O jogador deve escolher uma combinação de entradas para que a saída seja lógica 1. Não parece muito difícil. O jogo também gerou níveis automaticamente, o que sugere a capacidade de automatizar a criação de níveis, embora não pareça muito simples. O jogo também é bom para aprender, o que eu realmente gostei.
Prós:
- Simplicidade técnica de jogabilidade
- Parece fácil testar com autotestes
- Capacidade de gerar automaticamente níveis
Contras:
- Você deve primeiro criar níveis
Agora explore as falhas do jogo que inspiraram.
- Não adaptado à proporção personalizada, como 18: 9
- Não há como pular um nível difícil ou obter uma dica
- Nas análises, houve queixas sobre um pequeno número de níveis
- As críticas se queixaram da falta de variedade de elementos
Prosseguimos para o planejamento do nosso jogo:
- Usamos portas lógicas padrão (AND, NAND, OR, NOR, XOR, XNOR, NOR, NOT)
- Os portões são exibidos com uma imagem em vez de uma designação de texto, mais fácil de distinguir. Como os elementos possuem notação ANSI padrão, nós os usamos.
- Descartamos o comutador que conecta uma entrada a uma das saídas. Devido ao fato de exigir que você clique em si mesmo e não se encaixe um pouco nos elementos digitais reais. Sim, e é difícil imaginar um interruptor em um chip.
- Adicione os elementos do codificador e decodificador.
- Introduzimos um modo no qual o jogador deve selecionar o elemento desejado na célula com valores fixos nas entradas do circuito.
- Nós fornecemos ajuda ao jogador: dica + pular de nível.
- Seria bom adicionar algum enredo.
Jogabilidade
ConteúdoModo 1: O jogador recebe um circuito e tem acesso para alterar os valores nas entradas.
Modo 2: O jogador recebe um circuito no qual ele pode alterar os elementos, mas não pode alterar os valores nas entradas.
A jogabilidade será na forma de níveis pré-preparados. Depois de completar o nível, o jogador deve obter algum resultado, o que será feito na forma das três estrelas tradicionais, dependendo do resultado da passagem.
Quais podem ser os indicadores de desempenho:
Número de ações: cada interação com os elementos do jogo aumenta o contador.
O número de diferenças no estado resultante do original. Não leva em conta quantas tentativas o jogador teve que concluir. Infelizmente, ele não se encaixa no segundo regime.
Seria bom adicionar o mesmo modo com a geração aleatória de níveis. Mas, por enquanto, adie para mais tarde.
Traçar
ConteúdoEnquanto pensava na jogabilidade e iniciava o desenvolvimento, várias idéias pareciam melhorar o jogo. E uma idéia interessante o suficiente apareceu - para adicionar um enredo.
É sobre um engenheiro que projeta circuitos. Não é ruim, mas não completo.Talvez vale a pena exibir a fabricação de fichas com base no que o jogador faz? De alguma forma rotineira, não há resultado compreensível e simples.
A ideia! Um engenheiro desenvolve um robô legal usando seus circuitos lógicos. O robô é uma coisa compreensível bastante simples e se encaixa perfeitamente com a jogabilidade.
Lembre-se do primeiro parágrafo, "Requisitos mínimos para arte"? Algo não se encaixa nas cenas da trama. Então, um artista familiar vem em socorro, que concordou em nos ajudar.
Agora vamos decidir sobre o formato e a integração das cenas no jogo.
O gráfico deve ser exibido como cenas sem pontuação ou uma descrição de texto que remova problemas de localização, simplifique seu entendimento e muitos sejam reproduzidos em dispositivos móveis sem som. O jogo é um elemento muito real dos circuitos digitais, ou seja, é bem possível conectar isso à realidade.
Cortes e níveis devem ser cenas separadas. Antes de um certo nível, uma cena específica é carregada.
Bem, a tarefa está definida, há recursos a cumprir, o trabalho começou a ferver.
Desenvolvimento
ConteúdoEu imediatamente decidi na plataforma, esta é a Unity. Sim, um pouco exagerado, mas mesmo assim eu a conheço.
Durante o desenvolvimento, o código é escrito imediatamente com testes ou mesmo depois. Mas para uma narrativa holística, o teste é colocado em uma seção separada abaixo. A seção atual descreverá o processo de desenvolvimento separadamente do teste.
Core
ConteúdoO núcleo da jogabilidade parece bastante simples e não está vinculado ao mecanismo, então começamos com o design na forma de código C #. Parece que você pode selecionar uma lógica principal separada. Leve-o para um projeto separado.
O Unity trabalha com uma solução C # e os projetos internos são um pouco incomuns para um desenvolvedor .Net comum, os arquivos .sln e .csproj são gerados pelo próprio Unity e as alterações dentro desses arquivos não são aceitas para consideração no lado do Unity. Ele simplesmente os substituirá e excluirá todas as alterações. Para criar um novo projeto, você deve usar o arquivo de
Definição de Montagem .


O Unity agora gera um projeto com o nome apropriado. Tudo o que estiver na pasta com o arquivo .asmdef estará relacionado a este projeto e montagem.
Elementos elétricos
ConteúdoA tarefa é descrever no código a interação de elementos lógicos entre si.
- Um elemento pode ter várias entradas e saídas.
- A entrada do elemento deve estar conectada à saída de outro elemento
- O próprio elemento deve conter sua própria lógica.
Vamos começar.
- O elemento contém sua própria lógica de operação e links para suas entradas. Ao solicitar um valor de um elemento, ele recebe valores das entradas, aplica lógica a elas e retorna o resultado. Pode haver várias saídas, portanto, o valor para uma saída específica é solicitado, o padrão é 0.
- Para pegar os valores na entrada, haverá um conector de entrada p, ele armazena um link para outro - o conector de saída.
- O conector de saída se refere a um elemento específico e armazena um link para seu elemento. Ao solicitar um valor, ele solicita esse elemento.

As setas indicam a direção dos dados, a dependência dos elementos na direção oposta.
Defina a interface do conector. Você pode obter o valor disso.
public interface IConnector { bool Value { get; } }
Como conectá-lo a outro conector?
Defina mais interfaces.
public interface IInputConnector : IConnector { IOutputConnector ConnectedOtherConnector { get; set; } }
IInputConnector é um conector de entrada, possui um link para outro conector.
public interface IOutputConnector : IConnector { IElectricalElement Element { set; get; } }
O conector de saída refere-se ao seu elemento do qual solicitará um valor.
public interface IElectricalElement { bool GetValue(byte number = 0); }
O elemento elétrico deve conter um método que retorne um valor em uma saída específica; número é o número da saída.
Eu o chamei de IElectricalElement, embora transmita apenas níveis de tensão lógicos, mas, por outro lado, pode ser um elemento que não adiciona lógica, apenas transmite um valor, como um condutor.Agora vamos à implementação
public class InputConnector : IInputConnector { public IOutputConnector ConnectedOtherConnector { get; set; } public bool Value { get { return ConnectedOtherConnector?.Value ?? false; } } }
O conector de entrada pode não estar conectado; nesse caso, ele retornará 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); } }
A saída deve ter um link para seu elemento e seu número em relação ao elemento.
Além disso, usando esse número, ele solicita um valor ao elemento.
public abstract class ElectricalElementBase { public IInputConnector[] Input { get; set; } }
A classe base para todos os elementos, contém apenas uma matriz de entradas.
Exemplo de implementação de um 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; } }
A implementação é baseada inteiramente em operações lógicas sem uma tabela de verdade rígida. Talvez não seja tão explícito quanto na tabela, mas será flexível, funcionará em qualquer número de entradas.
Todas as portas lógicas têm uma saída, portanto, o valor na saída não dependerá do número de entrada.
Os elementos invertidos são feitos da seguinte maneira:
public class Nand : And, IElectricalElement { public new bool GetValue(byte number = 0) { return !base.GetValue(number); } }
Vale a pena notar que aqui o método GetValue é substituído, não substituído virtualmente. Isso é feito com base na lógica de que, se Nand salvar em And, ele continuará se comportando como And. Também foi possível aplicar a composição, mas isso exigiria código extra, o que não faz muito sentido.
Além das válvulas convencionais, foram criados os seguintes elementos:
Fonte - uma fonte de valor constante de 0 ou 1.
Condutor - exatamente o mesmo ou condutor, tem apenas uma aplicação ligeiramente diferente, veja geração.
AlwaysFalse - sempre retorna 0, necessário para o segundo modo.
Solver
ConteúdoA seguir, uma classe é útil para encontrar automaticamente combinações que dão 1 na saída do 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) {
As soluções são força bruta. Para isso, é determinado o número máximo que pode ser expresso por um conjunto de bits em uma quantidade igual ao número de fontes. Ou seja, 4 fontes = 4 bits = número máximo 15. Classificamos todos os números de 0 a 15.
ElementsProvider
ConteúdoPor conveniência de geração, decidi definir um número para cada elemento e, para isso, criei a classe ElementsProvider com a interface 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 }; }
As duas primeiras listas são como fábricas que fornecem um item no número especificado. As duas últimas listas são uma muleta que deve ser usada devido aos recursos do Unity. Sobre isso mais.
CircuitGenerator
ConteúdoAgora, a parte mais difícil do desenvolvimento é a geração de circuitos.
A tarefa é gerar uma lista de esquemas a partir da qual você pode selecionar o que mais gosta no editor. A geração é necessária apenas para válvulas simples.
Alguns parâmetros do esquema são definidos, são eles: o número de camadas (linhas horizontais de elementos) e o número máximo de elementos na camada. Também é necessário determinar de quais portas você precisa gerar circuitos.
Minha abordagem foi dividir a tarefa em duas partes - geração da estrutura e seleção de opções.
O gerador de estrutura determina as posições e conexões dos elementos lógicos.
O gerador de variantes seleciona combinações válidas de elementos em posições.
Structuregener
A estrutura consiste em camadas de elementos lógicos e camadas de condutores / inversores. Toda a estrutura não contém elementos reais, mas recipientes para eles.
O contêiner é uma classe herdada de IElectricalElement, que contém uma lista de elementos válidos e pode alternar entre eles. Cada item tem seu próprio número na lista.
ElectricalElementContainer : ElectricalElementBase, IElectricalElement
Um contêiner pode definir "ele mesmo" para um dos elementos da lista. Durante a inicialização, você deve fornecer uma lista de delegados que criarão os itens. No interior, ele chama todos os delegados e recebe o item. Em seguida, você pode definir o tipo específico deste elemento, isso conecta o elemento interno às mesmas entradas do contêiner e a saída do contêiner será obtida da saída desse elemento.

Método para definir a lista de elementos:
public void SetElements(IList<Func<IElectricalElement>> elements) { Elements = new List<IElectricalElement>(elements.Count); foreach (var item in elements) { Elements.Add(item()); } }
Em seguida, você pode definir o tipo desta maneira:
public void SetType(int number) { if (isInitialized == false) { throw new InvalidOperationException(UnitializedElementsExceptionMessage); } SelectedType = number; RealElement = Elements[number]; ((ElectricalElementBase) RealElement).Input = Input; }
Depois disso, ele funcionará como o item especificado.
A seguinte estrutura foi criada para o circuito:
public class CircuitStructure : ICloneable { public IDictionary<int, ElectricalElementContainer[]> Gates; public IDictionary<int, ElectricalElementContainer[]> Conductors; public Source[] Sources; public And FinalDevice; }
Os dicionários aqui armazenam o número da camada na chave e uma matriz de contêineres para essa camada. Em seguida, há uma matriz de fontes e um FinalDevice ao qual tudo está conectado.
Assim, o gerador estrutural cria contêineres e os conecta. Tudo isso é criado em camadas, de baixo para cima. A parte inferior é a mais larga (a maioria dos elementos). A camada acima contém duas vezes menos elementos e assim por diante até atingirmos o mínimo. As saídas de todos os elementos da camada superior são conectadas ao dispositivo final.
A camada do elemento lógico contém contêineres para portões. Na camada de condutores existem elementos com uma entrada e saída. Elementos lá podem ser um condutor ou um elemento NO. O condutor passa para a saída o que veio à entrada e o elemento NO retorna o valor invertido na saída.
O primeiro a criar uma matriz de fontes. A geração ocorre de baixo para cima, a camada de condutores é gerada primeiro, depois a camada de lógica e, na saída, novamente os condutores.

Mas esses esquemas são muito chatos! Queríamos simplificar ainda mais a nossa vida e decidimos tornar as estruturas geradas mais interessantes (complexas), optando por adicionar modificações na estrutura com ramificação ou conexão através de várias camadas.
Bem, dizer "simplificado" - isso significa complicar sua vida em outra coisa.
Gerar circuitos com o nível máximo de modificabilidade acabou sendo uma tarefa trabalhosa e não muito prática. Portanto, nossa equipe decidiu fazer o que atendia a esses critérios:
O desenvolvimento desta tarefa não demorou muito tempo.
Geração mais ou menos adequada de estruturas modificadas.
Não houve interseções entre os condutores.
Como resultado de uma programação longa e difícil, a solução foi escrita em 16:00.
Vamos dar uma olhada no código e em ̶̶̶̶̶̶̶̶̶̶.
Aqui a classe OverflowArray é encontrada. Por razões históricas, foi adicionado após a geração estrutural básica e tem mais a ver com geração de variantes, portanto, está localizado abaixo. 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; } }
Depois de visualizar este código, eu gostaria de entender o que está acontecendo nele.
Não se preocupe! Uma breve explicação sem detalhes se apressa.
A primeira coisa que fazemos é criar uma estrutura comum (base).
var baseStructure = GenerateStructure(lines, maxElementsInLine);
Então, como resultado de uma verificação simples, definimos o sinal de ramificação (branchingSign) para o valor apropriado.Por que isso é necessário? Além disso, ficará claro.
int maxValue = 1; int branchingSign = 1; if (modification == StructureModification.All) { maxValue = 2; branchingSign = 2; }
Agora, determinamos o comprimento do nosso OverflowArray e o inicializamos.
int lengthOverflowArray = baseStructure.Gates[(i * 2) + 1].Length; var elementArray = new OverflowArray(lengthOverflowArray, maxValue);
Para continuarmos nossas manipulações com a estrutura, precisamos descobrir o número de possíveis variações do nosso OverflowArray. Para fazer isso, existe uma fórmula que foi aplicada na próxima linha.
int lengthOverflowArray = baseStructure.Gates[(i * 2) + 1].Length;
A seguir, um loop aninhado no qual toda a “mágica” ocorre e para a qual havia todo esse prefácio.No início, aumentamos os valores de nossa matriz.
elementArray.Increase();
Depois disso, vemos uma verificação de validação, como resultado da qual vamos mais longe ou a próxima iteração.
if (modification == StructureModification.Branching || modification == StructureModification.All) { if (!CheckOverflowArrayForAllConnection(elementArray, branchingSign, lengthOverflowArray)) { continue; } }
Se a matriz passou na verificação de validação, clonamos nossa estrutura base. A clonagem é necessária, pois modificaremos nossa estrutura para muitas outras iterações.
E, finalmente, começamos a modificar a estrutura e limpá-la de elementos desnecessários. Eles se tornaram desnecessários como resultado de modificações estruturais.
ModifyStructure(structure, elementArray, key, modification); ClearStructure(structure);
Não vejo o ponto em mais detalhes para analisar dezenas de pequenas funções que são executadas "em algum lugar lá" nas profundezas.
Variantsgenerator
A estrutura + os elementos que devem estar nela são chamados CircuitVariant.
public struct CircuitVariant { public CircuitStructure Structure; public IDictionary<int, int[]> Gates; public IDictionary<int, int[]> Conductors; public IList<bool[]> Solutions; }
O primeiro campo é um link para a estrutura. Os segundos dois dicionários nos quais a chave é o número da camada e o valor é uma matriz que contém os números de elementos em seus locais na estrutura.
Prosseguimos com a seleção de combinações. Podemos ter um certo número de elementos lógicos e condutores válidos. No total, pode haver 6 elementos lógicos e 2 condutores.
Você pode imaginar um sistema numérico com base em 6 e inserir em cada categoria os números que correspondem aos elementos. Assim, aumentando esse número hexadecimal, você pode passar por todas as combinações de elementos.
Ou seja, um número hexadecimal de três dígitos será de 3 elementos. Vale apenas considerar que o número de elementos não 6, mas 4 pode ser transmitido.
Para descarregar esse número, eu determinei a estrutura
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; }
A seguir, uma classe com o nome estranho
OverflowArray . Sua essência é que ele armazena a matriz
ClampedInt e aumenta a ordem alta no caso de
ocorrer um estouro
na ordem
baixa e assim por diante até atingir o valor máximo em todas as células.
De acordo com cada ClampedInt, os valores do ElectricalElementContainer correspondente são definidos. Assim, é possível classificar todas as combinações possíveis. É importante notar que, se você deseja gerar um esquema com elementos (por exemplo, And (0) e Xor (4)), não é necessário classificar todas as opções, incluindo os elementos 1,2,3. Para isso, durante a geração, os elementos obtêm seus números locais (por exemplo, And = 0, Xor = 1) e depois são convertidos novamente em números globais.
Assim, você pode iterar sobre todas as combinações possíveis em todos os elementos.
Depois que os valores nos contêineres são definidos, o circuito é verificado quanto a soluções usando o
Solver . Se o circuito passar a decisão, ele retornará.
Depois que o circuito é gerado, o número de soluções é verificado. Não deve exceder o limite e não deve ter decisões consistindo inteiramente de 0 ou 1.
Muito 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 um dos geradores retorna uma variante usando a declaração de rendimento. Assim, CircuitGenerator usando StructureGenerator e VariantsGenerator gera IEnumerable (a abordagem com rendimento ajudou muito no futuro, veja abaixo).
Após o fato de o gerador de opções receber a lista de estruturas. Você pode gerar opções para cada estrutura independentemente. Isso pode ser paralelo, mas adicionar o AsParallel não funcionou (provavelmente o rendimento interfere). O paralelismo manual será muito demorado, porque descartamos essa opção.
Na verdade, tentei fazer geração paralela, funcionou, mas houve algumas dificuldades, porque não foi para o repositório.Classes de jogos
Abordagem de Desenvolvimento e DI
ConteúdoO projeto é construído em
Injeção de Dependência (DI). Isso significa que as classes podem simplesmente exigir a si mesmas algum tipo de objeto correspondente à interface e não estar envolvidas na criação desse objeto. Quais são os benefícios:
- O local de criação e inicialização do objeto de dependência é definido em um local e separado da lógica das classes dependentes, o que remove a duplicação de código.
- Elimina a necessidade de desenterrar toda a árvore de dependências e instanciar todas as dependências.
- Permite alterar facilmente a implementação da interface, que é usada em muitos lugares.
Como um contêiner de DI, o projeto usa o
Zenject .
O Zenject tem vários contextos, eu uso apenas dois deles:
- Contexto do projeto - registro de dependências em todo o aplicativo.
- Contexto da cena: o registro de classes que existem apenas em uma cena específica e sua vida útil é limitada pelo tempo de vida da cena.
- Um contexto estático é um contexto geral para tudo em geral, a peculiaridade é que ele existe no editor. Eu uso para injeção no editor
O registro da classe é armazenado no
Installer s. Eu uso
ScriptableObjectInstaller para o contexto do projeto e
MonoInstaller para o contexto da cena.
A maioria das classes que eu registro no AsSingle, uma vez que não contêm estado, é mais provável que sejam apenas contêineres para métodos. Eu uso AsTransient para classes em que há um estado interno que não deve ser comum a outras classes.Depois disso, você precisa criar de alguma forma as classes MonoBehaviour que representarão esses elementos. Também aloquei classes relacionadas ao Unity para um projeto separado, dependendo do projeto Core.
Para as classes MonoBehaviour, prefiro criar minhas próprias interfaces. Isso, além das vantagens padrão das interfaces, permite ocultar um número muito grande de membros do MonoBehaviour.Por conveniência, o DI geralmente cria uma classe simples que executa toda a lógica e o wrapper MonoBehaviour para ela. Por exemplo, a classe possui métodos Start e Update, eu os crio na classe e, na classe MonoBehaviour, adiciono um campo de dependência e, nos métodos correspondentes, chamo Start and Update. Isso fornece a injeção "correta" ao construtor, o destacamento da classe principal do recipiente DI e a capacidade de testar facilmente.Configuração
ConteúdoPor configuração, quero dizer dados comuns a todo o aplicativo. No meu caso, são prefabs, identificadores para publicidade e compras, tags, nomes de cenas etc. Para esses propósitos, eu uso ScriptableObjects:- Para cada grupo de dados, uma classe descendente ScriptableObject é alocada.
- Cria os campos serializáveis necessários
- As propriedades de leitura desses campos são adicionadas.
- DI
- Lucro
public interface ITags { string FixedColor { get; } string BackgroundColor { get; } string ForegroundColor { get; } string AccentedColor { get; } } [CreateAssetMenu(fileName = nameof(Tags), menuName = "Configuration/" + nameof(Tags))] public class Tags : ScriptableObject, ITags { [SerializeField] private string fixedColor; [SerializeField] private string backgroundColor; [SerializeField] private string foregroundColor; [SerializeField] private string accentedColor; public string FixedColor => fixedColor; public string BackgroundColor => backgroundColor; public string ForegroundColor => foregroundColor; public string AccentedColor => accentedColor; private void OnEnable() { fixedColor.AssertNotEmpty(nameof(fixedColor)); backgroundColor.AssertNotEmpty(nameof(backgroundColor)); foregroundColor.AssertNotEmpty(nameof(foregroundColor)); accentedColor.AssertNotEmpty(nameof(accentedColor)); } }
( ):
CreateAssetMenu(fileName = nameof(ConfigurationInstaller), menuName = "Installers/" + nameof(ConfigurationInstaller))] public class ConfigurationInstaller : ScriptableObjectInstaller<ConfigurationInstaller> { [SerializeField] private EditorElementsPrefabs editorElementsPrefabs; [SerializeField] private LevelCompletionSteps levelCompletionSteps; [SerializeField] private CommonValues commonValues; [SerializeField] private AdsConfiguration adsConfiguration; [SerializeField] private CutscenesConfiguration cutscenesConfiguration; [SerializeField] private Colors colors; [SerializeField] private Tags tags; public override void InstallBindings() { Container.Bind<IEditorElementsPrefabs>().FromInstance(editorElementsPrefabs).AsSingle(); Container.Bind<ILevelCompletionSteps>().FromInstance(levelCompletionSteps).AsSingle(); Container.Bind<ICommonValues>().FromInstance(commonValues).AsSingle(); Container.Bind<IAdsConfiguration>().FromInstance(adsConfiguration).AsSingle(); Container.Bind<ICutscenesConfiguration>().FromInstance(cutscenesConfiguration).AsSingle(); Container.Bind<IColors>().FromInstance(colors).AsSingle(); Container.Bind<ITags>().FromInstance(tags).AsSingle(); } private void OnEnable() { editorElementsPrefabs.AssertNotNull(); levelCompletionSteps.AssertNotNull(); commonValues.AssertNotNull(); adsConfiguration.AssertNotNull(); cutscenesConfiguration.AssertNotNull(); colors.AssertNOTNull(); tags.AssertNotNull(); } }
-
public interface IElectricalElementMb { GameObject GameObject { get; } string Name { get; set; } IElectricalElement Element { get; set; } IOutputConnectorMb[] OutputConnectorsMb { get; } IInputConnectorMb[] InputConnectorsMb { get; } Transform Transform { get; } void SetInputConnectorsMb(InputConnectorMb[] inputConnectorsMb); void SetOutputConnectorsMb(OutputConnectorMb[] outputConnectorsMb); } [DisallowMultipleComponent] public class ElectricalElementMb : MonoBehaviour, IElectricalElementMb { [SerializeField] private OutputConnectorMb[] outputConnectorsMb; [SerializeField] private InputConnectorMb[] inputConnectorsMb; public Transform Transform => transform; public GameObject GameObject => gameObject; public string Name { get => name; set => name = value; } public virtual IElectricalElement Element { get; set; } public IOutputConnectorMb[] OutputConnectorsMb => outputConnectorsMb; public IInputConnectorMb[] InputConnectorsMb => inputConnectorsMb; }
public interface IInputConnectorMb : IConnectorMb { IOutputConnectorMb OutputConnectorMb { get; set; } IInputConnector InputConnector { get; } }
public class InputConnectorMb : MonoBehaviour, IInputConnectorMb { [SerializeField] private OutputConnectorMb outputConnectorMb; public Transform Transform => transform; public IOutputConnectorMb OutputConnectorMb { get => outputConnectorMb; set => outputConnectorMb = (OutputConnectorMb) value; } public IInputConnector InputConnector { get; } = new InputConnector(); #if UNITY_EDITOR private void OnDrawGizmos() { if (outputConnectorMb != null) { Handles.DrawLine(transform.position, outputConnectorMb.Transform.position); } } #endif }
public IElectricalElement Element { get; set; }
?
generic:
public class ElectricalElementMb: MonoBehaviour, IElectricalElementMb where T: IElectricalElement
, Unity generic MonoBehaviour-. , Unity .
, IElectricalElement Element { get; set; }
valor desejado.Criei enum ElectricalElementType no qual haverá todos os tipos necessários. O Enum é bem serializado pelo Unity e bem exibido no Inspetor como uma lista suspensa. Definidos dois tipos de elemento: que é criado em tempo de execução e que é criado no editor e pode ser salvo. Portanto, há IElectricalElementMb e IElectricalElementMbEditor, que também contêm um campo do tipo ElectricalElementType.O segundo tipo também precisa ser inicializado em tempo de execução. Para fazer isso, há uma classe que no início ignorará todos os elementos e os inicializará, dependendo do tipo no campo enum. Como segue: 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()} };
Gerenciamento de jogos
, ( , )?.. , .
-, .
DataManager . AsSingle . , . , DataManager.
IFileStoreService ,
IFileSerializer .
LevelGameManager .
GodObject, UI, , . , . ..
LevelGameManager1 LevelGameManager2 1 2 .
.
No segundo caso, a lógica responde a um evento de mudança de elemento e também verifica os valores na saída do circuito.Existem algumas informações sobre o nível atual, como número do nível e assistência ao jogador.Os dados sobre o nível atual são armazenados em CurrentLevelData . Um número de nível é armazenado lá - uma propriedade booleana com uma verificação de ajuda, um sinalizador de oferta para avaliar o jogo e dados para ajudar o jogador. 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; } }
A ajuda para o primeiro modo são os números e valores de origem neles. No segundo modo, esse é o tipo de elemento que precisa ser definido na célula.A coleção contém estruturas que armazenam a posição e o valor que devem ser definidos na posição especificada. Um dicionário seria mais bonito, mas o Unity não pode serializar dicionários.As diferenças entre as cenas dos diferentes modos são que, no contexto da cena, outro LevelGameManager e outro ICurrentLevelData são definidos .Em geral, tenho uma abordagem orientada a eventos para a comunicação de elementos. Por um lado, é lógico e conveniente. Por outro lado, há uma oportunidade de obter problemas sem cancelar a inscrição quando necessário. No entanto, não houve problemas neste projeto e a escala não é muito grande. Geralmente, uma assinatura ocorre durante o início da cena para tudo o que você precisa. Quase nada é criado em tempo de execução, portanto não há confusão.Carregamento nivelado
ConteúdoCada nível no jogo é representado por uma cena do Unity, ele deve conter um prefixo de nível e um número, por exemplo, "Level23". O prefixo está incluído na configuração. O carregamento do nível ocorre pelo nome, formado a partir do prefixo. Assim, a classe LevelsManager pode carregar níveis por número.Cutscenes
O conteúdo dacena são cenas de unidade comuns com números no título, semelhantes aos níveis.A animação em si é implementada usando a Linha do tempo. Infelizmente, não tenho habilidades de animação nem a capacidade de trabalhar com a Linha do tempo; portanto, “não atire no pianista - ele toca o máximo que pode”.
A verdade é que uma cena lógica deve consistir em diferentes cenas com diferentes objetos. Aconteceu que isso foi notado um pouco tarde, mas foi decidido simplesmente: colocando partes das cenas no palco em locais diferentes e movendo instantaneamente a câmera.
Jogabilidade adicional
ConteúdoO jogo é avaliado pelo número de ações por nível e pelo uso de pistas. Quanto menos ação, melhor. O uso da dica de ferramenta reduz a classificação máxima para 2 estrelas, pulando o nível para 1 estrela. Para avaliar a passagem, o número de etapas para a passagem é armazenado. Consiste em dois valores: o valor mínimo (para 3 estrelas) e o máximo (1 estrela).O número de etapas para a passagem de níveis não é armazenado no próprio arquivo de cena, mas no arquivo de configuração, pois é necessário exibir o número de estrelas para o nível passado. Isso complicou um pouco o processo de criação de níveis. Foi especialmente interessante ver mudanças no sistema de controle de versão:
Tente adivinhar a que nível pertence. Era possível armazenar o dicionário, é claro, mas em primeiro lugar não era serializado pelo Unity, no segundo, seria necessário definir manualmente os números.Se for difícil para o jogador completar o nível, ele pode obter uma dica - os valores corretos em algumas entradas ou o elemento correto no segundo modo. Isso também foi feito manualmente, embora pudesse ser automatizado.Se a ajuda do jogador não ajudou, ele pode pular completamente o nível. Em caso de falta de nível, o jogador recebe 1 estrela por ele.Um usuário que passou por um nível com uma dica não pode executá-lo novamente por um tempo, de modo que seria difícil executar novamente o nível com memória nova, como se não tivesse uma dica.Monetização
ConteúdoExistem dois tipos de monetização no jogo: exibir anúncios e desativar anúncios por dinheiro. Uma exibição de anúncio inclui a exibição de anúncios entre níveis e a exibição de anúncios recompensados para pular um nível.Se o jogador estiver disposto a pagar por desativar a publicidade, ele poderá fazê-lo. Nesse caso, os anúncios entre os níveis e ao pular um nível não serão exibidos.Para publicidade, foi criada uma classe chamada AdsService , com uma 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; } }
Aqui, o HelpAd é um anúncio recompensado por pular um nível. Inicialmente, chamamos ajuda parcial e total. Parcial é uma dica e completo é um nível de salto.Esta classe contém dentro da limitação da frequência de exibição de anúncios por tempo, após o primeiro lançamento do jogo.A implementação usa o plug-in do Google Mobile Ads Unity .Com a publicidade recompensada, entrei em um rake - acontece que delegados leais podem ser chamados em outro tópico, não está muito claro o porquê. Portanto, é melhor que esses delegados não chamem nada no código relacionado ao Unity. Se uma compra foi feita para desativar a publicidade, o anúncio não será exibido e o delegado executará imediatamente a exibição bem-sucedida do anúncio.Existe uma interface para fazer compras public interface IPurchaseService { bool IsAdsDisablePurchased { get; } event Action DisableAdsPurchased; void BuyDisableAds(); void RemoveDisableAd(); }
O IAP da unidade é usado na implementação.Háum truque para comprar desconexões de anúncios. O Google Play parece não fornecer informações de que o jogador comprou uma compra. Apenas uma confirmação chegará de que ela passou uma vez. Mas se você colocar o status do produto após a compra não concluída, mas pendente, isso permitirá que você verifique a propriedade do produto hasReceipt . Se for verdade, a compra foi concluída.Embora, é claro, confunda essa abordagem, suspeito que talvez não seja tudo tranquilo.O método RemoveDisableAd é necessário no momento do teste e remove a falha de publicidade adquirida.Interface do usuário
ConteúdoTodos os elementos da interface funcionam de acordo com uma abordagem orientada a eventos. Os próprios elementos da interface geralmente não contêm lógica além de eventos chamados por métodos públicos que o Unity pode usar. Embora também ocorra algumas tarefas relacionadas apenas à 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(); } }
De fato, esse nem sempre é o caso. É bom deixar esses elementos como Visualização ativa, fazer dele um ouvinte de evento, algo como um controlador que acionará as ações necessárias nos gerentes.Google Analytics
ConteúdoNo caminho de menor resistência, a análise da Unity foi escolhida . Fácil de implementar, embora limitado por uma assinatura gratuita - é impossível exportar os dados de origem. Há também um limite no número de eventos - 100 / hora por jogador.Para análises, criou a classe de wrapper AnalyticsService . Possui métodos para cada tipo de evento, recebe os parâmetros necessários e faz com que o evento seja enviado usando as ferramentas incorporadas no Unity. Criar um método para cada evento certamente não é a melhor prática como um todo, mas em um projeto conscientemente pequeno é melhor do que fazer algo grande e complicado.Todos os eventos usados são CustomEvent. . . AnalyticsService e .
. ScriptableObject, .
:
public void LevelComplete(int number, int stars, int actionCount, TimeSpan timeSpent, int levelMode) { CustomEvent(LevelCompleteEventName, new Dictionary<string, object> { {LevelNumber, number}, {LevelStars, stars}, {LevelActionCount, actionCount}, {LevelTimeSpent, timeSpent}, {LevelMode, levelMode} }); }
FinalDevice , Sources . , , .
CameraAlign . :
- Encontre todos os elementos necessários no palco
- Encontre a largura e altura mínimas com base na proporção
- Determinar o tamanho da câmera
- Coloque a câmera no centro
- Mova o FinalDevice para o topo da tela
- Mover fontes para a parte inferior da tela
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 é chamado quando a cena começa na classe wrapper.Esquemas de cores
ConteúdoComo o jogo terá uma interface muito primitiva, decidi fazê-lo com dois esquemas de cores, preto e branco.Para isso, criou uma interface public interface IColors { Color ColorAccent { get; } Color Background { get; set; } Color Foreground { get; set; } event Action ColorsChanged; }
As cores podem ser definidas diretamente no editor do Unity; isso pode ser usado para teste. Então eles podem ser trocados e têm dois conjuntos de cores.As cores de fundo e primeiro plano podem mudar, com um toque de cor em qualquer modo.Como o player pode definir um tema não padrão, os dados de cores devem ser armazenados no arquivo de configurações. Se o arquivo de configurações não contiver dados de cores, eles serão preenchidos com valores padrão.Existem várias classes: CameraColorAdjustment - responsável por definir a cor de fundo da câmera, UiColorAdjustment - definir as cores dos elementos da interface e TextMeshColorAdjustment- define a cor dos números nas fontes. O UiColorAdjustment também usa tags. No editor, você pode marcar cada elemento com uma tag que indicará para que tipo de cor deve ser definida (Background, Foreground, AccentColor e FixedColor). Tudo está definido no início da cena ou no evento de uma alteração no esquema de cores.Resultado:


Extensões do editor
ConteúdoPara simplificar e acelerar o processo de desenvolvimento, muitas vezes é necessário criar a ferramenta certa, que não é fornecida pelas ferramentas padrão do editor. A abordagem tradicional no Unity é criar uma classe descendente do EditorWindow. Também existe uma abordagem com os UiElements, mas ela ainda está em desenvolvimento, então decidi usar a abordagem tradicional.Se você simplesmente criar uma classe que usa algo do espaço para nome do UnityEditor ao lado de outras classes para o jogo, o projeto simplesmente não será montado, pois esse espaço para nome não está disponível na compilação. Existem várias soluções:- Selecione um projeto separado para scripts do editor
- Coloque os arquivos na pasta Ativos / Editor
- Coloque esses arquivos em #if UNITY_EDITOR
O projeto usa a primeira abordagem e, por vezes, #if UNITY_EDITOR, se necessário, adiciona uma pequena parte do editor à classe necessária na compilação.Todas as classes necessárias apenas no editor que defini na montagem, que estarão disponíveis apenas no editor. Ela não irá para a construção do jogo.
Seria bom agora ter DI em suas extensões de editor. Para isso eu uso Zenject.StaticContext. Para defini-lo no editor, é usada uma classe com o atributo InitializeOnLoad, na qual existe um construtor 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 classes ScriptableObject em um contexto estático, eu uso o seguinte 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; }
A implementação de TI é necessária apenas para esta linha AssetDatabase.LoadAssetAtPath (path)Não é possível adicionar uma dependência ao construtor. Em vez disso, adicione o atributo [Inject] aos campos de dependência na classe window e chameStaticContext.Container.Inject (this) na inicialização da janela ;Também recomendo adicionar ao ciclo de atualização da janela uma verificação nula de um dos campos dependentes e, se o campo estiver vazio, execute a linha acima. Porque, depois de alterar o código no projeto, o Unity pode recriar a janela e não chamar Desperta.Gerador
Conteúdo A
visão inicial do geradorA janela deve fornecer uma interface para gerar uma lista de esquemas com parâmetros, exibir uma lista de esquemas e colocar o esquema selecionado na cena atual.A janela consiste em três seções da esquerda para a direita:- configurações de geração
- lista de opções na forma de botões
- opção selecionada como texto
As colunas são criadas usando EditorGUILayout.BeginVertical () e EditorGUILayout.EndVertical (). Infelizmente, não funcionou para corrigir e limitar os tamanhos, mas isso não é tão crítico.Verificou-se que o processo de geração em um grande número de circuitos não é tão rápido. Muitas combinações são obtidas com os elementos de I. Como o criador de perfil mostrou, a parte mais lenta é o próprio circuito. Paralelamente, não é uma opção; todas as opções usam um esquema, mas é difícil clonar essa estrutura.Então pensei que provavelmente todo o código das extensões do editor funcione no modo Debug. Em Release, a depuração não funciona tão bem, os pontos de interrupção não param, as linhas são ignoradas etc. De fato, tendo medido o desempenho, descobriu-se que a velocidade do gerador no Unity corresponde ao conjunto de depuração iniciado pelo aplicativo do console, que é aproximadamente 6 vezes mais lento que o Release. Tenha isso em mente.
Unity DLL , .
Task :
circuitGenerator.Generate(lines, maxElementsInLine, availableLogicalElements, useNOT, modification).ToList()
, . , ( 20 ). , .
internal static class Ext { public static IEnumerable<CircuitVariant> OrderVariants(this IEnumerable<CircuitVariant> circuitVariants) { return circuitVariants.OrderBy(a => a.Solutions.Count()) .ThenByDescending(a => a.Solutions .Select(b => b.Sum(i => i ? 1 : -1)) .OrderByDescending(b=>b) .First()); } } public interface IEditorGenerator : IDisposable { CircuitVariant[] FilteredVariants { get; } int LastPage { get; } void FilterVariants(int page); void Start(int lines, int maxElementsInLine, ICollection<int> availableGates, bool useNOT, StructureModification? modification, int maxSolutions); void Stop(); void Fetch(); } public class EditorGenerator : IEditorGenerator { private const int PageSize = 100; private readonly ICircuitGenerator circuitGenerator; private ConcurrentBag<CircuitVariant> variants; private List<CircuitVariant> sortedVariants; private Thread generatingThread; public EditorGenerator(ICircuitGenerator circuitGenerator) { this.circuitGenerator = circuitGenerator; } public void Dispose() { generatingThread?.Abort(); } public CircuitVariant[] FilteredVariants { get; private set; } public int LastPage { get; private set; } public void FilterVariants(int page) { CheckVariants(); if (sortedVariants == null) { Fetch(); } FilteredVariants = sortedVariants.Skip(page * PageSize) .Take(PageSize) .ToArray(); int count = sortedVariants.Count; LastPage = count % PageSize == 0 ? (count / PageSize) - 1 : count / PageSize; } public void Fetch() { CheckVariants(); sortedVariants = variants.OrderVariants() .ToList(); } public void Start(int lines, int maxElementsInLine, ICollection<int> availableGates, bool useNOT, StructureModification? modification, int maxSolutions) { if (generatingThread != null) { Stop(); } variants = new ConcurrentBag<CircuitVariant>(); generatingThread = new Thread(() => { var v = circuitGenerator.Generate(lines, maxElementsInLine, availableGates, useNOT, modification, maxSolutions); foreach (var item in v) { variants.Add(item); } }); generatingThread.Start(); } public void Stop() { generatingThread?.Abort(); sortedVariants = null; variants = null; generatingThread = null; FilteredVariants = null; } private void CheckVariants() { if (variants == null) { throw new InvalidOperationException("VariantsGeneration is not started. Use Start before."); } } ~EditorGenerator() { generatingThread.Abort(); } }
A idéia é que o plano de fundo seja gerado e, mediante solicitação, a lista interna de opções classificadas será atualizada. Então você pode página por página para selecionar opções. Portanto, não há necessidade de classificar cada vez, o que acelera significativamente o trabalho em grandes listas. Os esquemas são classificados por “interesse”: pelo número de soluções, por aumento e por quantos valores são necessários para a solução. Ou seja, um circuito com uma solução de 1 1 1 1 é menos interessante que 1 0 1 1.
Assim, verificou-se, sem esperar o final da geração, já selecionar um circuito para o nível. Outra vantagem é que, devido à paginação, o editor não diminui a velocidade como o gado.O recurso Unity é muito perturbador, pois quando você clica em Play, o conteúdo da janela é redefinido, como todos os dados gerados. Se eles fossem facilmente serializáveis, eles poderiam ser armazenados como arquivos. Dessa maneira, você pode até armazenar em cache os resultados da geração. Mas, infelizmente, serializar uma estrutura complexa onde os objetos se referem um ao outro é difícil.Além disso, adicionei linhas a cada portão, como if (Input.Length == 2) { return Input[0].Value && Input[1].Value; }
O que melhorou bastante o desempenho.Solver
ConteúdoQuando você monta um circuito no editor, precisa entender rapidamente se ele está sendo resolvido e quantas soluções ele possui. Para fazer isso, criei uma janela "solucionador". Ele fornece soluções para o esquema atual na forma de um texto.A
lógica de seu "back-end": 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
ConteúdoAsserthelp
ConteúdoPara verificar se os valores estão definidos em ativos, eu uso métodos de extensão que chamo no 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; } }
Às vezes, verificar se a cena pode ser carregada pode falhar, embora a cena possa ser carregada. Talvez isso seja um bug no Unity.Exemplos de uso: mainMenuSceneName.AssertNOTEmpty(nameof(mainMenuSceneName)).AssertSceneCanBeLoaded(); levelNamePrefix.AssertNOTEmpty(nameof(levelNamePrefix)); editorElementsPrefabs.AssertNOTNull(); not.AssertType(ElectricalElementType.NOT);
SceneObjectsHelper
ConteúdoPara trabalhar com elementos de cena, a classe SceneObjectsHelper também foi útil:Muito 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; } } }
Aqui, algumas coisas podem não ser muito eficazes quando é necessário alto desempenho, mas raramente são necessárias para mim e não criam nenhuma influência. Mas eles permitem que você encontre objetos pela interface, por exemplo, o que parece bastante bonito.Coroutinestarter
Conteúdo Olançamento da Coroutine pode apenas o MonoBehaviour. Então eu criei a classe CoroutineStarter e a registrei no contexto da cena. public interface ICoroutineStarter { void BeginCoroutine(IEnumerator routine); } public class CoroutineStarter : MonoBehaviour, ICoroutineStarter { public void BeginCoroutine(IEnumerator routine) { StartCoroutine(routine); } }
Além da conveniência, a introdução de tais ferramentas facilitou o teste automático. Por exemplo, a execução da corotina em testes: coroutineStarter.When(x => x.BeginCoroutine(Arg.Any<IEnumerator>())).Do(info => { var a = (IEnumerator) info[0]; while (a.MoveNext()) { } });
Gizmo
ConteúdoPara a conveniência de exibir elementos invisíveis, recomendo o uso de imagens de aparelhos que são visíveis apenas na cena. Eles facilitam a seleção de um elemento invisível com um clique. Também foram feitas conexões de elementos na forma de linhas: private void OnDrawGizmos() { if (outputConnectorMb != null) { Handles.DrawLine(transform.position, outputConnectorMb.Transform.position); } }

Teste
ConteúdoEu queria tirar o máximo proveito dos testes automáticos, porque os testes eram usados sempre que possível e fáceis de usar.Para testes de unidade, é habitual usar objetos simulados em vez das classes que implementam a interface da qual a classe de teste depende. Para isso, usei a biblioteca NSubstitute . O que está muito satisfeito.O Unity não oferece suporte ao NuGet; portanto, tive que obter a DLL separadamente e, em seguida, o assembly, pois uma dependência é adicionada ao arquivo AssemblyDefinition e usada sem problemas.
Para testes automáticos, o Unity oferece o TestRunner, que funciona com a estrutura de teste NUnit muito popular . Do ponto de vista do TestRunner, existem dois tipos de testes:- EditMode — , . Nunit . , . GameObject Monobehaviour . , EditMode .
- PlayMode — .
EditMode Na minha experiência, houve muitos inconvenientes e comportamentos estranhos nesse modo. No entanto, é conveniente verificar automaticamente a integridade do aplicativo como um todo. Eles também fornecem verificação honesta do código em métodos como Iniciar, Atualizar e similares.Os testes do PlayMode podem ser descritos como testes normais do NUnit, mas há uma alternativa. No PlayMode, pode ser necessário esperar um pouco ou um certo número de quadros. Para fazer isso, os testes devem ser descritos de maneira semelhante à Coroutine. O valor retornado deve ser IEnumerator / IEnumerable e por dentro, para ignorar o tempo, você deve usar, por exemplo: yield return null;
ou
yield return new WaitForSeconds(1);
Existem outros valores de retorno.Esse teste precisa definir o atributo UnityTest . Também existematributos UnitySetUp e UnityTearDown com os quais você precisa usar uma abordagem semelhante.Por sua vez, compartilho os testes EditMode para Modular e Integração.Os testes de unidade testam apenas uma classe em completo isolamento de outras classes. Esses testes geralmente facilitam a preparação do ambiente para a classe testada e os erros, quando aprovados, permitem localizar o problema com mais precisão.Nos testes de unidade, eu testo muitas classes principais e necessárias diretamente no jogo.Os testes dos elementos do circuito são muito semelhantes, então eu criei uma classe 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) {
Testes de elementos adicionais são assim: public class AndTests : ElectricalElementTestsBase<And> { [TestCase(false, false, false)] [TestCase(false, true, false)] [TestCase(true, false, false)] [TestCase(true, true, true)] public new void GetValue_2Input(bool input1, bool input2, bool output) { base.GetValue_2Input(input1, input2, output); } [TestCase(false, false)] [TestCase(true, true)] public new void GetValue_1Input(bool input, bool expectedOutput) { base.GetValue_1Input(input, expectedOutput); } }
, , 11 .
GameManager-. , . . . , :
[Test] public void FullHelpAgree_FinishLevel() {
, DI . , .
public class PlacerTests { [Inject] private ICircuitEditorPlacer circuitEditorPlacer; [Inject] private ICircuitGenerator circuitGenerator; [Inject] private IEditorSolver solver; [Inject] private ISceneObjectsHelper sceneObjectsHelper; [TearDown] public void TearDown() { sceneObjectsHelper.DestroyObjectsOfType<IElectricalElementMb>(immediate: true); } [OneTimeSetUp] public void Setup() { var container = StaticContext.Container; container.Inject(this); } [TestCase(1, 2)] [TestCase(2, 2)] [TestCase(3, 4)] public void PlaceSolve_And_NoModifications_AllVariantsSolved(int lines, int elementsInLine) { var variants = circuitGenerator.Generate(lines, elementsInLine, new List<int> {0}, false); foreach (var variant in variants) { circuitEditorPlacer.PlaceCircuit(variant); var solutions = solver.FindSolutions(); CollectionAssert.IsNOTEmpty(solutions); } } [TestCase(1, 2, StructureModification.Branching)] [TestCase(1, 2, StructureModification.ThroughLayer)] [TestCase(1, 2, StructureModification.All)] [TestCase(2, 2, StructureModification.Branching)] [TestCase(2, 2, StructureModification.ThroughLayer)] [TestCase(2, 2, StructureModification.All)] public void PlaceSolve_And_Modifications_AllVariantsSolved(int lines, int elementsInLine, StructureModification modification) { var variants = circuitGenerator.Generate(lines, elementsInLine, new List<int> {0}, false, modification); foreach (var variant in variants) { circuitEditorPlacer.PlaceCircuit(variant); var solutions = solver.FindSolutions(); CollectionAssert.IsNOTEmpty(solutions); } }
Este teste usa implementações reais de todas as dependências e também define objetos no palco, o que é bastante possível nos testes EditMode. É verdade que testamos que eles os colocaram sãos - tenho pouca idéia de como, portanto, verifico se o circuito publicado tem soluções.Na integração, também existem testes para CircuitGenerator (StructureGenerator + VariantsGenerator) e 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() {
Os testes do PlayMode são usados como testes do sistema. Eles verificam pré-fabricados, injeção, etc. Uma boa opção é usar cenas prontas nas quais o teste apenas carrega e produz algumas interações. Mas eu uso uma cena vazia preparada para teste, na qual o ambiente é diferente do que estará no jogo. Houve uma tentativa de usar o PlayMode para testar todo o processo do jogo, como entrar no menu, entrar no nível etc., mas o trabalho desses testes acabou sendo instável, por isso foi decidido adiá-lo para mais tarde (nunca).É conveniente usar ferramentas de avaliação de cobertura para escrever testes, mas infelizmente não encontrei nenhuma solução trabalhando com o Unity.Eu encontrei um problema que, com a atualização do Unity para 2018.3, os testes começaram a funcionar muito mais devagar, até 10 vezes mais devagar (em um exemplo sintético). O projeto contém 288 testes EditMode que são executados por 11 segundos, embora nada tenha sido feito por tanto tempo.Resumo do Desenvolvimento
Conteúdo
Captura de tela do nível do jogo Alógica de alguns jogos pode ser formulada independentemente da plataforma. Em um estágio inicial, isso facilita o desenvolvimento e a testabilidade pelos autotestes.DI é conveniente. Mesmo levando em conta o fato de que o Unity não o possui de forma nativa, o parafuso parafusado funciona com bastante tolerância.O Unity permite testar automaticamente um projeto. É verdade que todos os componentes internos do GameObject não têm interface e só podem ser usados diretamente para zombar de coisas como Collider, SpriteRenderer, MeshRenderer, etc. não vai dar certo. Embora o GetComponent permita obter componentes na interface. Como opção, escreva seus próprios invólucros para tudo., . ., , / . DI, , scriptable objects , , , Zenject, , .
Unidade gera uma enorme quantidade de erros, falhas. Muitas vezes, os erros são resolvidos reiniciando o editor. Diante de uma estranha perda de referências a objetos em casas pré-fabricadas. Às vezes, a pré-fabricada por referência é destruída (ToString () retorna "nulo"), embora tudo pareça funcionar, a pré-fabricada é arrastada para a cena e o link não está vazio. Às vezes, algumas conexões são perdidas em todas as cenas. Tudo parece estar instalado, funcionou, mas ao mudar para outro ramo, todas as cenas estão quebradas - não há links entre os elementos.Felizmente, esses erros costumavam ser corrigidos reiniciando o editor ou algumas vezes excluindo a pasta Biblioteca.No total, cerca de meio ano passou da ideia para a publicação no Google Play. O desenvolvimento em si levou cerca de 3 meses, em tempo livre, do trabalho principal.