
Je souhaite partager le processus de développement d'un jeu mobile simple par deux développeurs et un artiste. Cet article est en grande partie une description de la mise en œuvre technique.
Attention, beaucoup de texte!
L'article n'est pas un guide ou une leçon, bien que j'espère que les lecteurs pourront en tirer quelque chose d'utile. Conçu pour les développeurs familiers avec Unity avec une certaine expérience en programmation.
Contenu:
IdéeGameplayTerrainDéveloppementNoyau- Éléments électriques
- Solveur
- ElementsProvider
- CircuitGenerator
Cours de jeu- Approche de développement et DI
- La configuration
- Éléments électriques
- Gestion du jeu
- Chargement de niveau
- Cinématiques
- Gameplay supplémentaire
- Monétisation
- Interface utilisateur
- Analytique
- Positionnement et diagrammes de la caméra
- Jeux de couleurs
Extensions de l'éditeur
- Générateur
- Solveur
Utile- Asserthelp
- SceneObjectsHelper
- Coroutinestarter
- Gizmo
TestRésumé du développementIdée
Table des matièresIl y avait une idée de faire un jeu mobile simple en peu de temps.
Termes:
- Jeu facile à mettre en œuvre
- Exigences artistiques minimales
- Temps de développement court (plusieurs mois)
- Avec une automatisation facile de la création de contenu (niveaux, emplacements, éléments de jeu)
- Créez rapidement un niveau si le jeu se compose d'un nombre fini de niveaux
Afin de décider, mais que faire réellement? Après tout, l'idée est venue de faire un jeu, pas l'idée d'un jeu. Il a été décidé de s'inspirer de l'App Store.
Aux éléments ci-dessus sont ajoutés:
- Le jeu devrait avoir une certaine popularité auprès des joueurs (nombre de téléchargements + notes)
- L'App Store ne doit pas être encombré de jeux similaires
Un jeu a été trouvé avec un gameplay basé sur des portes logiques. Il n'y en avait pas de similaires en grand nombre. Le jeu a de nombreux téléchargements et des notes positives. Néanmoins, après avoir essayé, il y avait quelques inconvénients qui peuvent être pris en compte dans votre jeu.
Le gameplay du jeu est que le niveau est un circuit numérique avec de nombreuses entrées et sorties. Le joueur doit choisir une combinaison d'entrées pour que la sortie soit logique 1. Cela ne semble pas très difficile. Le jeu a également généré automatiquement des niveaux, ce qui suggère que la possibilité d'automatiser la création de niveaux, bien que cela ne semble pas très simple. Le jeu est également bon pour l'apprentissage, ce que j'ai vraiment aimé.
Avantages:
- Simplicité technique du gameplay
- Semble facile à tester avec les autotests
- Possibilité de générer automatiquement des niveaux
Inconvénients:
- Vous devez d'abord créer des niveaux
Explorez maintenant les défauts du jeu qui vous a inspiré.
- Non adapté au format d'image personnalisé, comme 18: 9
- Il n'y a aucun moyen de sauter un niveau difficile ou d'obtenir un indice
- Dans les critiques, il y avait des plaintes concernant un petit nombre de niveaux
- Les critiques se sont plaintes du manque de variété des éléments
Nous procédons à la planification de notre jeu:
- Nous utilisons des portes logiques standard (AND, NAND, OR, NOR, XOR, XNOR, NOR, NOT)
- Les portes sont affichées avec une image au lieu d'une désignation de texte, ce qui est plus facile à distinguer. Puisque les éléments ont une notation ANSI standard, nous les utilisons.
- Nous rejetons le commutateur qui connecte une entrée à l'une des sorties. En raison du fait que cela vous oblige à cliquer sur vous-même et ne correspond pas un peu aux vrais éléments numériques. Oui, et il est difficile d'imaginer un interrupteur à bascule dans une puce.
- Ajoutez les éléments de l'encodeur et du décodeur.
- Nous introduisons un mode dans lequel le joueur doit sélectionner l'élément souhaité dans la cellule avec des valeurs fixes aux entrées du circuit.
- Nous aidons le joueur: indice + niveau de saut.
- Ce serait bien d'ajouter une intrigue.
Gameplay
Table des matièresMode 1: Le joueur reçoit un circuit et a accès pour changer les valeurs aux entrées.
Mode 2: Le joueur reçoit un circuit dans lequel il peut changer les éléments mais ne peut pas changer les valeurs aux entrées.
Le gameplay prendra la forme de niveaux pré-préparés. Après avoir terminé le niveau, le joueur doit obtenir un certain résultat, ce qui se fera sous la forme des trois étoiles traditionnelles, selon le résultat du passage.
Quels peuvent être les indicateurs de performance:
Nombre d'actions: chaque interaction avec les éléments du jeu augmente le compteur.
Le nombre de différences dans l'état résultant de l'original. Ne prend pas en compte le nombre de tentatives que le joueur a dû effectuer. Malheureusement, cela ne correspond pas au deuxième régime.
Ce serait bien d'ajouter le même mode avec une génération de niveau aléatoire. Mais pour l'instant, remettez-le pour plus tard.
Terrain
Table des matièresEn réfléchissant au gameplay et en commençant le développement, diverses idées ont semblé améliorer le jeu. Et une idée assez intéressante est apparue - ajouter un complot.
Il s'agit d'un ingénieur qui conçoit des circuits. Pas mal, mais pas complet. Peut-être vaut-il la peine d'afficher la fabrication des puces en fonction de ce que fait le joueur? D'une certaine manière routinière, il n'y a pas de résultat simple et compréhensible.
L'idée! Un ingénieur développe un robot cool en utilisant ses circuits logiques. Le robot est une chose assez simple et compréhensible et s'adapte parfaitement au gameplay.
Rappelez-vous le premier paragraphe, «Exigences minimales pour l'art»? Quelque chose ne correspond pas aux cinématiques de l'intrigue. Puis un artiste familier vient à la rescousse, qui a accepté de nous aider.
Décidons maintenant du format et de l'intégration des cinématiques dans le jeu.
L'intrigue doit être affichée sous forme de cinématiques sans notation ni description textuelle qui supprimera les problèmes de localisation, simplifiera sa compréhension et beaucoup joueront sur des appareils mobiles sans son. Le jeu est un élément très réel des circuits numériques, c'est-à-dire qu'il est tout à fait possible de le connecter à la réalité.
Les cinématiques et les niveaux doivent être des scènes distinctes. Avant un certain niveau, une scène spécifique est chargée.
Eh bien, la tâche est fixée, il y a des ressources à remplir, le travail a commencé à bouillir.
Développement
Table des matièresJ'ai immédiatement décidé de la plate-forme, c'est Unity. Oui un peu exagéré, mais néanmoins je la connais.
Pendant le développement, le code est écrit immédiatement avec des tests ou même après. Mais pour un récit holistique, les tests sont placés dans une section distincte ci-dessous. La section actuelle décrira le processus de développement séparément des tests.
Noyau
Table des matièresLe cœur du gameplay semble assez simple et n'est pas lié au moteur, nous avons donc commencé par la conception sous forme de code C #. Il semble que vous pouvez sélectionner une logique de cœur de base séparée. Sortez-le dans un projet distinct.
Unity fonctionne avec une solution C # et les projets à l'intérieur sont un peu inhabituels pour un développeur .Net ordinaire, les fichiers .sln et .csproj sont générés par Unity lui-même et les modifications à l'intérieur de ces fichiers ne sont pas acceptées pour examen du côté Unity. Il les remplacera simplement et supprimera toutes les modifications. Pour créer un nouveau projet, vous devez utiliser le fichier de
définition d'assemblage .


Unity génère désormais un projet avec le nom approprié. Tout ce qui se trouve dans le dossier contenant le fichier .asmdef sera lié à ce projet et à cet assemblage.
Éléments électriques
Table des matièresLa tâche consiste à décrire dans le code l'interaction des éléments logiques entre eux.
- Un élément peut avoir plusieurs entrées et plusieurs sorties.
- L'entrée de l'élément doit être connectée à la sortie d'un autre élément
- L'élément lui-même doit contenir sa propre logique.
Commençons.
- L'élément contient sa propre logique de fonctionnement et est lié à ses entrées. Lorsque vous demandez une valeur à un élément, il prend des valeurs des entrées, leur applique une logique et renvoie le résultat. Il peut y avoir plusieurs sorties, donc la valeur pour une sortie spécifique est demandée, la valeur par défaut est 0.
- Pour prendre les valeurs à l'entrée, il y aura un connecteur d'entrée p, il stocke un lien vers un autre - le connecteur de sortie.
- Le connecteur de sortie fait référence à un élément spécifique et stocke un lien vers son élément, lorsqu'il demande une valeur, il le demande à l'élément.

Les flèches indiquent la direction des données, la dépendance des éléments dans la direction opposée.
Définissez l'interface du connecteur. Vous pouvez en tirer la valeur.
public interface IConnector { bool Value { get; } }
Comment le connecter à un autre connecteur?
Définissez plus d'interfaces.
public interface IInputConnector : IConnector { IOutputConnector ConnectedOtherConnector { get; set; } }
IInputConnector est un connecteur d'entrée, il a un lien vers un autre connecteur.
public interface IOutputConnector : IConnector { IElectricalElement Element { set; get; } }
Le connecteur de sortie fait référence à son élément à partir duquel il demandera une valeur.
public interface IElectricalElement { bool GetValue(byte number = 0); }
L'élément électrique doit contenir une méthode qui renvoie une valeur sur une sortie spécifique, nombre est le numéro de la sortie.
Je l'ai appelé IElectricalElement, bien qu'il ne transmette que des niveaux de tension logiques, mais d'un autre côté, il peut être un élément qui n'ajoute pas du tout de logique, passe juste une valeur, comme un conducteur.Passons maintenant à l'implémentation
public class InputConnector : IInputConnector { public IOutputConnector ConnectedOtherConnector { get; set; } public bool Value { get { return ConnectedOtherConnector?.Value ?? false; } } }
Le connecteur entrant n'est peut-être pas connecté, auquel cas il retournera faux.
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 sortie doit avoir un lien vers son élément et son numéro par rapport à l'élément.
De plus, en utilisant ce nombre, il demande une valeur à l'élément.
public abstract class ElectricalElementBase { public IInputConnector[] Input { get; set; } }
La classe de base pour tous les éléments, contient juste un tableau d'entrées.
Exemple d'implémentation d'un élément:
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; } }
L'implémentation est entièrement basée sur des opérations logiques sans table de vérité dure. Peut-être pas aussi explicite qu'avec le tableau, mais il sera flexible, il fonctionnera sur n'importe quel nombre d'entrées.
Toutes les portes logiques ont une sortie, donc la valeur à la sortie ne dépendra pas du numéro d'entrée.
Les éléments inversés sont réalisés comme suit:
public class Nand : And, IElectricalElement { public new bool GetValue(byte number = 0) { return !base.GetValue(number); } }
Il convient de noter qu'ici, la méthode GetValue est remplacée et non virtuellement. Cela se fait sur la base de la logique selon laquelle si Nand enregistre dans Et, il continuera à se comporter comme Et. Il était également possible d'appliquer la composition, mais cela nécessiterait du code supplémentaire, ce qui n'a pas beaucoup de sens.
En plus des vannes conventionnelles, les éléments suivants ont été créés:
Source - une source de valeur constante de 0 ou 1.
Conducteur - exactement le même Ou conducteur, n'a qu'une application légèrement différente, voir génération.
AlwaysFalse - renvoie toujours 0, nécessaire pour le deuxième mode.
Solveur
Table des matièresEnsuite, une classe est utile pour trouver automatiquement des combinaisons qui donnent 1 à la sortie du circuit.
public interface ISolver { ICollection<bool[]> GetSolutions(IElectricalElement root, params Source[] sources); } public class Solver : ISolver { public ICollection<bool[]> GetSolutions(IElectricalElement root, params Source[] sources) {
Les solutions sont la force brute. Pour cela, on détermine le nombre maximum qui peut être exprimé par un ensemble de bits en une quantité égale au nombre de sources. Autrement dit, 4 sources = 4 bits = nombre maximum 15. Nous trions tous les nombres de 0 à 15.
ElementsProvider
Table des matièresPour faciliter la génération, j'ai décidé de définir un numéro pour chaque élément. Pour ce faire, j'ai créé la classe ElementsProvider avec l'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 }; }
Les deux premières listes sont quelque chose comme des usines qui donnent un article au nombre spécifié. Les deux dernières listes sont une béquille qui doit être utilisée en raison des caractéristiques d'Unity. À ce sujet plus loin.
CircuitGenerator
Table des matièresMaintenant, la partie la plus difficile du développement est la génération de circuits.
La tâche consiste à générer une liste de schémas à partir de laquelle vous pouvez ensuite sélectionner celui que vous aimez dans l'éditeur. La génération n'est nécessaire que pour les vannes simples.
Certains paramètres du schéma sont définis, ce sont: le nombre de couches (lignes horizontales d'éléments) et le nombre maximum d'éléments dans la couche. Il est également nécessaire de déterminer à partir de quelles portes vous devez générer des circuits.
Mon approche était de diviser la tâche en deux parties - la génération de la structure et la sélection des options.
Le générateur de structure détermine les positions et les connexions des éléments logiques.
Le générateur de variantes sélectionne des combinaisons valides d'éléments dans des positions.
Structuregener
La structure se compose de couches d'éléments logiques et de couches de conducteurs / onduleurs. L'ensemble de la structure ne contient pas de vrais éléments mais des conteneurs pour eux.
Le conteneur est une classe héritée de IElectricalElement, qui contient à l'intérieur une liste d'éléments valides et peut basculer entre eux. Chaque élément a son propre numéro dans la liste.
ElectricalElementContainer : ElectricalElementBase, IElectricalElement
Un conteneur peut définir «lui-même» sur l'un des éléments de la liste. Lors de l'initialisation, vous devez lui donner une liste des délégués qui créeront les éléments. À l'intérieur, il appelle chaque délégué et récupère l'article. Ensuite, vous pouvez définir le type spécifique de cet élément, cela connecte l'élément interne aux mêmes entrées que dans le conteneur et la sortie du conteneur sera prise à partir de la sortie de cet élément.

Méthode de définition de la liste des éléments:
public void SetElements(IList<Func<IElectricalElement>> elements) { Elements = new List<IElectricalElement>(elements.Count); foreach (var item in elements) { Elements.Add(item()); } }
Ensuite, vous pouvez définir le type de cette façon:
public void SetType(int number) { if (isInitialized == false) { throw new InvalidOperationException(UnitializedElementsExceptionMessage); } SelectedType = number; RealElement = Elements[number]; ((ElectricalElementBase) RealElement).Input = Input; }
Après quoi, il fonctionnera comme l'élément spécifié.
La structure suivante a été créée pour le circuit:
public class CircuitStructure : ICloneable { public IDictionary<int, ElectricalElementContainer[]> Gates; public IDictionary<int, ElectricalElementContainer[]> Conductors; public Source[] Sources; public And FinalDevice; }
Les dictionnaires stockent ici le numéro de couche dans la clé et un tableau de conteneurs pour cette couche. Vient ensuite un tableau de sources et un FinalDevice auquel tout est connecté.
Ainsi, le générateur structurel crée des conteneurs et les relie les uns aux autres. Tout est créé en couches, de bas en haut. Le fond est le plus large (la plupart des éléments). La couche ci-dessus contient deux fois moins d'éléments et ainsi de suite jusqu'à atteindre un minimum. Les sorties de tous les éléments de la couche supérieure sont connectées à l'appareil final.
La couche d'élément logique contient des conteneurs pour les portes. Dans la couche de conducteurs, il y a des éléments avec une entrée et une sortie. Les éléments peuvent être soit un conducteur soit un élément NO. Le conducteur passe à la sortie ce qui est arrivé à l'entrée et l'élément NO renvoie la valeur inversée à la sortie.
Le premier à créer un tableau de sources. La génération se fait de bas en haut, la couche de conducteurs est générée d'abord, puis la couche de logique, et à la sortie de celle-ci à nouveau les conducteurs.

Mais de tels régimes sont très ennuyeux! Nous voulions simplifier encore plus notre vie et avons décidé de rendre les structures générées plus intéressantes (complexes) .Il a été décidé d'ajouter des modifications de structure avec branchement ou connexion à travers de nombreuses couches.
Eh bien, dire «simplifié» - cela signifie compliquer votre vie dans autre chose.
La génération de circuits avec le niveau maximum de modifiabilité s'est avérée être une tâche laborieuse et pas tout à fait pratique. Par conséquent, notre équipe a décidé de faire ce qui répondait à ces critères:
Le développement de cette tâche n'a pas pris beaucoup de temps.
Génération plus ou moins adéquate de structures modifiées.
Il n'y avait aucune intersection entre les conducteurs.
À la suite d'une programmation longue et difficile, la solution a été écrite en 16 heures.
Jetons un coup d'oeil au code et ̶̶̶̶̶̶̶̶̶̶.
Ici, la classe OverflowArray est rencontrée. Pour des raisons historiques, il a été ajouté après la génération structurelle de base et a plus à voir avec la génération de variantes, il est donc situé ci-dessous. Lien 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; } }
Après avoir vu ce code, je voudrais comprendre ce qui s'y passe.
Ne t'inquiète pas! Une brève explication sans détails vous presse.
La première chose que nous faisons est de créer une structure (de base) ordinaire.
var baseStructure = GenerateStructure(lines, maxElementsInLine);
Ensuite, à la suite d'une simple vérification, nous définissons le signe de branchement (branchingSign) sur la valeur appropriée. Pourquoi est-ce nécessaire? De plus, ce sera clair.
int maxValue = 1; int branchingSign = 1; if (modification == StructureModification.All) { maxValue = 2; branchingSign = 2; }
Maintenant, nous déterminons la longueur de notre OverflowArray et l'initialisons.
int lengthOverflowArray = baseStructure.Gates[(i * 2) + 1].Length; var elementArray = new OverflowArray(lengthOverflowArray, maxValue);
Afin de continuer nos manipulations avec la structure, nous devons trouver le nombre de variations possibles de notre OverflowArray. Pour ce faire, une formule a été appliquée sur la ligne suivante.
int lengthOverflowArray = baseStructure.Gates[(i * 2) + 1].Length;
Vient ensuite une boucle imbriquée dans laquelle a lieu toute la «magie» et pour laquelle il y avait toute cette préface. Au tout début, nous augmentons les valeurs de notre tableau.
elementArray.Increase();
Après cela, nous voyons un contrôle de validation, à la suite duquel nous allons plus loin ou la prochaine itération.
if (modification == StructureModification.Branching || modification == StructureModification.All) { if (!CheckOverflowArrayForAllConnection(elementArray, branchingSign, lengthOverflowArray)) { continue; } }
Si le tableau a réussi la vérification de validation, nous clonons notre structure de base. Le clonage est nécessaire car nous allons modifier notre structure pour de nombreuses autres itérations.
Et enfin, nous commençons à modifier la structure et à la nettoyer des éléments inutiles. Ils sont devenus inutiles à la suite de modifications structurelles.
ModifyStructure(structure, elementArray, key, modification); ClearStructure(structure);
Je ne vois pas le point plus en détail d'analyser des dizaines de petites fonctions qui sont exécutées «quelque part là-bas» dans les profondeurs.
Générateur de variantes
La structure + les éléments qui devraient y être sont appelés CircuitVariant.
public struct CircuitVariant { public CircuitStructure Structure; public IDictionary<int, int[]> Gates; public IDictionary<int, int[]> Conductors; public IList<bool[]> Solutions; }
Le premier champ est un lien vers la structure. Les deux deuxièmes dictionnaires dans lesquels la clé est le numéro de la couche et la valeur est un tableau qui contient le nombre d'éléments à leur place dans la structure.
Nous procédons à la sélection des combinaisons. Nous pouvons avoir un certain nombre d'éléments logiques et de conducteurs valides. Au total, il peut y avoir 6 éléments logiques et 2 conducteurs.
Vous pouvez imaginer un système de nombres avec une base de 6 et obtenir dans chaque catégorie les nombres qui correspondent aux éléments. Ainsi, en augmentant ce nombre hexadécimal, vous pouvez parcourir toutes les combinaisons d'éléments.
Autrement dit, un nombre hexadécimal de trois chiffres sera composé de 3 éléments. Il ne faut que considérer que le nombre d'éléments non 6 mais 4 peut être transmis.
Pour décharger un tel nombre, j'ai déterminé la structure
public struct ClampedInt { public int Value { get => value; set => this.value = Mathf.Clamp(value, 0, MaxValue); } public readonly int MaxValue; private int value; public ClampedInt(int maxValue) { MaxValue = maxValue; value = 0; } public bool TryIncrease() { if (Value + 1 <= MaxValue) { Value++; return false; }
Vient ensuite une classe avec le nom étrange
OverflowArray . Son essence est qu'il stocke le tableau
ClampedInt et augmente l'ordre élevé dans le cas où un débordement
se produit dans l' ordre
faible et ainsi de suite jusqu'à ce qu'il atteigne la valeur maximale dans toutes les cellules.
En fonction de chaque ClampedInt, les valeurs du ElectricalElementContainer correspondant sont définies. Ainsi, il est possible de trier toutes les combinaisons possibles. Il convient de noter que si vous souhaitez générer un schéma avec des éléments (par exemple, And (0) et Xor (4)), vous n'avez pas besoin de trier toutes les options, y compris les éléments 1,2,3. Pour cela, lors de la génération, les éléments obtiennent leurs nombres locaux (par exemple, And = 0, Xor = 1), et ensuite ils sont reconvertis en nombres globaux.
Vous pouvez donc parcourir toutes les combinaisons possibles dans tous les éléments.
Une fois les valeurs dans les conteneurs définies, le circuit est vérifié pour trouver des solutions à l'aide de
Solver . Si le circuit passe la décision, il revient.
Une fois le circuit généré, le nombre de solutions est vérifié. Il ne doit pas dépasser la limite et ne doit pas comporter de décisions entièrement composées de 0 ou de 1.
Beaucoup de code public interface IVariantsGenerator { IEnumerable<CircuitVariant> Generate(IEnumerable<CircuitStructure> structures, ICollection<int> availableGates, bool useNot, int maxSolutions = int.MaxValue); } public class VariantsGenerator : IVariantsGenerator { private readonly ISolver solver; private readonly IElementsProvider elementsProvider; public VariantsGenerator(ISolver solver, IElementsProvider elementsProvider) { this.solver = solver; this.elementsProvider = elementsProvider; } public IEnumerable<CircuitVariant> Generate(IEnumerable<CircuitStructure> structures, ICollection<int> availableGates, bool useNot, int maxSolutions = int.MaxValue) { bool manyGates = availableGates.Count > 1; var availableLeToGeneralNumber = GetDictionaryFromAllowedElements(elementsProvider.Gates, availableGates); var gatesList = GetElementsList(availableLeToGeneralNumber, elementsProvider.Gates); var availableConductorToGeneralNumber = useNot ? GetDictionaryFromAllowedElements(elementsProvider.Conductors, new[] {0, 1}) : GetDictionaryFromAllowedElements(elementsProvider.Conductors, new[] {0}); var conductorsList = GetElementsList(availableConductorToGeneralNumber, elementsProvider.Conductors); foreach (var structure in structures) { InitializeCircuitStructure(structure, gatesList, conductorsList); var gates = GetListFromLayersDictionary(structure.Gates); var conductors = GetListFromLayersDictionary(structure.Conductors); var gatesArray = new OverflowArray(gates.Count, availableGates.Count - 1); var conductorsArray = new OverflowArray(conductors.Count, useNot ? 1 : 0); do { if (useNot && conductorsArray.EqualInts) { continue; } SetContainerValuesAccordingToArray(conductors, conductorsArray); do { if (manyGates && gatesArray.Length > 1 && gatesArray.EqualInts) { continue; } SetContainerValuesAccordingToArray(gates, gatesArray); var solutions = solver.GetSolutions(structure.FinalDevice, structure.Sources); if (solutions.Any() && solutions.Count <= maxSolutions && !(solutions.Any(s => s.All(b => b)) || solutions.Any(s => s.All(b => !b)))) { var variant = new CircuitVariant { Conductors = GetElementsNumberFromLayers(structure.Conductors, availableConductorToGeneralNumber), Gates = GetElementsNumberFromLayers(structure.Gates, availableLeToGeneralNumber), Solutions = solutions, Structure = structure }; yield return variant; } } while (!gatesArray.Increase()); } while (useNot && !conductorsArray.Increase()); } } private static void InitializeCircuitStructure(CircuitStructure structure, IList<Func<IElectricalElement>> gates, IList<Func<IElectricalElement>> conductors) { var lElements = GetListFromLayersDictionary(structure.Gates); foreach (var item in lElements) { item.SetElements(gates); } var cElements = GetListFromLayersDictionary(structure.Conductors); foreach (var item in cElements) { item.SetElements(conductors); } } private static IList<Func<IElectricalElement>> GetElementsList(IDictionary<int, int> availableToGeneralGate, IReadOnlyList<Func<IElectricalElement>> elements) { var list = new List<Func<IElectricalElement>>(); foreach (var item in availableToGeneralGate) { list.Add(elements[item.Value]); } return list; } private static IDictionary<int, int> GetDictionaryFromAllowedElements(IReadOnlyCollection<Func<IElectricalElement>> allElements, IEnumerable<int> availableElements) { var enabledDic = new Dictionary<int, bool>(allElements.Count); for (int i = 0; i < allElements.Count; i++) { enabledDic.Add(i, false); } foreach (int item in availableElements) { enabledDic[item] = true; } var availableToGeneralNumber = new Dictionary<int, int>(); int index = 0; foreach (var item in enabledDic) { if (item.Value) { availableToGeneralNumber.Add(index, item.Key); index++; } } return availableToGeneralNumber; } private static void SetContainerValuesAccordingToArray(IReadOnlyList<ElectricalElementContainer> containers, IOverflowArray overflowArray) { for (int i = 0; i < containers.Count; i++) { containers[i].SetType(overflowArray[i].Value); } } private static IReadOnlyList<ElectricalElementContainer> GetListFromLayersDictionary(IDictionary<int, ElectricalElementContainer[]> layers) { var elements = new List<ElectricalElementContainer>(); foreach (var layer in layers) { elements.AddRange(layer.Value); } return elements; } private static IDictionary<int, int[]> GetElementsNumberFromLayers(IDictionary<int, ElectricalElementContainer[]> layers, IDictionary<int, int> elementIdToGlobal = null) { var dic = new Dictionary<int, int[]>(layers.Count); bool convert = elementIdToGlobal != null; foreach (var layer in layers) { var values = new int[layer.Value.Length]; for (int i = 0; i < layer.Value.Length; i++) { if (!convert) { values[i] = layer.Value[i].SelectedType; } else { values[i] = elementIdToGlobal[layer.Value[i].SelectedType]; } } dic.Add(layer.Key, values); } return dic; } }
Chacun des générateurs renvoie une variante à l'aide de la déclaration de rendement. Ainsi, CircuitGenerator utilisant StructureGenerator et VariantsGenerator génère IEnumerable (l'approche avec le rendement a beaucoup aidé à l'avenir, voir ci-dessous).
Suite au fait que le générateur d'options reçoit la liste des structures. Vous pouvez générer des options pour chaque structure indépendamment. Cela pourrait être mis en parallèle, mais l'ajout de AsParallel n'a pas fonctionné (probablement produire des interférences). La parallélisation manuelle sera longue, car nous rejetons cette option.
En fait, j'ai essayé de faire de la génération parallèle, cela a fonctionné, mais il y a eu quelques difficultés, car cela ne s'est pas rendu dans le référentiel.Cours de jeu
Approche de développement et DI
Table des matièresLe projet est construit sous
Dependency Injection (DI). Cela signifie que les classes peuvent simplement exiger elles-mêmes une sorte d'objet correspondant à l'interface et ne pas être impliquées dans la création de cet objet. Quels sont les avantages:
- Le lieu de création et d'initialisation de l'objet de dépendance est défini en un seul endroit et séparé de la logique des classes dépendantes, ce qui supprime la duplication de code.
- Élimine le besoin de déterrer l'arborescence de dépendances entière et d'instancier toutes les dépendances.
- Vous permet de modifier facilement l'implémentation de l'interface, qui est utilisée à de nombreux endroits.
En tant que conteneur DI dans le projet,
Zenject est utilisé.
Zenject a plusieurs contextes, j'en utilise seulement deux:
- Contexte du projet - enregistrement des dépendances dans l'ensemble de l'application.
- Contexte de la scène: enregistrement des classes qui n'existent que dans une scène particulière et leur durée de vie est limitée par la durée de vie de la scène.
- Un contexte statique est un contexte général pour tout en général, la particularité est qu'il existe dans l'éditeur. J'utilise pour l'injection dans l'éditeur
L'inscription aux cours est stockée dans
Installer s. J'utilise
ScriptableObjectInstaller pour le contexte du projet et
MonoInstaller pour le contexte de la scène.
La plupart des classes que j'inscris avec AsSingle, car elles ne contiennent pas d'état, sont plus probablement des conteneurs de méthodes. J'utilise AsTransient pour les classes où il existe un état interne qui ne devrait pas être commun aux autres classes.Après cela, vous devez en quelque sorte créer des classes MonoBehaviour qui représenteront ces éléments. J'ai également alloué des classes liées à Unity à un projet distinct en fonction du projet Core.
Pour les classes MonoBehaviour, je préfère créer mes propres interfaces. Ceci, en plus des avantages standard des interfaces, vous permet de masquer un très grand nombre de membres MonoBehaviour.Pour plus de commodité, DI crée souvent une classe simple qui exécute toute la logique et un wrapper MonoBehaviour pour cela. Par exemple, la classe a des méthodes Start et Update, je crée de telles méthodes dans la classe, puis dans la classe MonoBehaviour j'ajoute un champ de dépendance et dans les méthodes correspondantes j'appelle Start et Update. Cela donne l'injection «correcte» au constructeur, le détachement de la classe principale du conteneur DI et la possibilité de tester facilement.La configuration
ContenuPar configuration, je veux dire des données communes à l'ensemble de l'application. Dans mon cas, ce sont des préfabriqués, des identifiants pour la publicité et les achats, des tags, des noms de scènes, etc. À ces fins, j'utilise ScriptableObjects:- Pour chaque groupe de données, une classe descendante ScriptableObject est allouée.
- Il crée les champs sérialisables nécessaires
- Les propriétés de lecture de ces champs sont ajoutées.
- L'interface avec les champs ci-dessus est mise en évidence
- Une classe s'enregistre auprès d'une interface dans un conteneur DI
- Bénéfice
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)); } }
Pour la configuration, un programme d'installation distinct (code abrégé): 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(); } }
Éléments électriques
ContenuMaintenant, vous devez imaginer les éléments électriques 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 }
Nous avons la ligne Public IElectricalElement Element {get; ensemble; }
Voici seulement comment installer cet article?Une bonne option serait de rendre générique:classe publique ElectricalElementMb: MonoBehaviour, IElectricalElementMb où T: IElectricalElementMais le hic est que Unity ne prend pas en charge les génériques dans les classes MonoBehavior. De plus, Unity ne prend pas en charge la sérialisation des propriétés et des interfaces.Néanmoins, en runtime, il est tout à fait possible de passer dans IElectricalElement Element {get; ensemble; }
valeur souhaitée.J'ai fait enum ElectricalElementType dans lequel il y aura tous les types nécessaires. Enum est bien sérialisé par Unity et bien affiché dans l'inspecteur sous forme de liste déroulante. Défini deux types d'élément: qui est créé lors de l'exécution et qui est créé dans l'éditeur et qui peut être enregistré. Ainsi, il existe IElectricalElementMb et IElectricalElementMbEditor, qui contient en outre un champ de type ElectricalElementType.Le deuxième type doit également être initialisé lors de l'exécution. Pour ce faire, il existe une classe qui au départ contournera tous les éléments et les initialisera en fonction du type dans le champ enum. Comme suit: 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()} };
Gestion du jeu
ContenuEnsuite, la question se pose, où placer la logique du jeu lui-même (vérifier les conditions de passage, compter les lectures du passage et aider le joueur)? .. Il y a aussi des questions sur l'emplacement de la logique pour sauvegarder et charger la progression, les paramètres et d'autres choses.Pour cela, je distingue certaines classes de managers qui sont responsables d'une certaine classe de tâches.DataManager est responsable du stockage des données des résultats de la transmission des paramètres utilisateur et jeu. Il est enregistré par AsSingle dans le cadre du projet. Cela signifie qu'il est un pour toute l'application. Pendant l'exécution de l'application, les données sont stockées directement en mémoire, à l'intérieur du DataManager.Il utilise IFileStoreService , qui est responsable du chargement et de la sauvegarde des données et IFileSerializerresponsable de la sérialisation des fichiers sous une forme prête à l'emploi pour l'enregistrement.LevelGameManager est un gestionnaire de jeu dans une seule scène.J'ai un petit GodObject, car il est toujours responsable de l'interface utilisateur, c'est-à-dire l'ouverture et la fermeture du menu, la réaction aux boutons. Mais il est acceptable, compte tenu de la taille du projet et de l'absence de nécessité de l'étendre, donc une séquence d'actions encore plus simple et plus visible.Il y a deux options. C'est ce que LevelGameManager1 et LevelGameManager2 sont appelés respectivement pour les modes 1 et 2.Dans le premier cas, la logique est basée sur la réaction à l'éventualité d'un changement de valeur dans l'une des sources et sur la vérification de la valeur en sortie du circuit.Dans le second cas, la logique répond à un événement de changement d'élément et vérifie également les valeurs à la sortie du circuit.Il existe des informations sur le niveau actuel, telles que le numéro de niveau et l'aide au joueur.Les données sur le niveau actuel sont stockées dans CurrentLevelData . Un numéro de niveau y est stocké - une propriété booléenne avec une vérification de l'aide, un indicateur d'offre pour évaluer le jeu et des données pour aider le joueur. 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; } }
L'aide pour le premier mode est les numéros de source et les valeurs qu'ils contiennent. Dans le deuxième mode, c'est le type d'élément qui doit être défini dans la cellule.La collection contient des structures qui stockent la position et la valeur qui doivent être définies à la position spécifiée. Un dictionnaire serait plus joli, mais Unity ne peut pas sérialiser les dictionnaires.Les différences entre les scènes de différents modes sont que dans le contexte de la scène, un autre LevelGameManager et un autre ICurrentLevelData sont définis .En général, j'ai une approche événementielle de la communication des éléments. D'une part, c'est logique et pratique. D'un autre côté, il est possible de rencontrer des problèmes sans se désinscrire lorsque cela est nécessaire. Néanmoins, il n'y a pas eu de problèmes dans ce projet, et l'échelle n'est pas trop grande. Habituellement, un abonnement se produit au début de la scène pour tout ce dont vous avez besoin. Presque rien n'est créé lors de l'exécution, il n'y a donc pas de confusion.Chargement de niveau
ContenuChaque niveau du jeu est représenté par une scène Unity, il doit contenir un préfixe de niveau et un numéro, par exemple, «Level23». Le préfixe est inclus dans la configuration. Le chargement du niveau se fait par nom, qui est formé à partir du préfixe. Ainsi, la classe LevelsManager peut charger des niveaux par nombre.Cinématiques
Le contenu de lacinématique est des scènes d'unité ordinaires avec des nombres dans le titre, similaires aux niveaux.L'animation elle-même est implémentée à l'aide de Timeline. Malheureusement, je n'ai ni compétences en animation ni la capacité de travailler avec Timeline, alors "ne tirez pas sur le pianiste - il joue comme il peut".
La vérité s'est avérée qu'une cinématique logique devrait se composer de différentes scènes avec différents objets. Il s'est avéré que cela a été remarqué un peu tard, mais cela a été décidé simplement: en plaçant des parties des cinématiques sur la scène à différents endroits et en déplaçant instantanément la caméra.
Gameplay supplémentaire
ContenuLe jeu est évalué par le nombre d'actions par niveau et l'utilisation d'indices. Moins il y a d'action, mieux c'est. L'utilisation de l'info-bulle réduit la note maximale à 2 étoiles, sautant le niveau à 1 étoile. Pour évaluer le passage, le nombre d'étapes de passage est enregistré. Il se compose de deux valeurs: la valeur minimale (pour 3 étoiles) et la maximale (1 étoile).Le nombre d'étapes pour passer les niveaux n'est pas stocké dans le fichier de scène lui-même, mais dans le fichier de configuration, car vous devez afficher le nombre d'étoiles pour le niveau passé. Cela a légèrement compliqué le processus de création de niveaux. Il était particulièrement intéressant de voir des changements dans le système de contrôle de version:
Essayez de deviner à quel niveau il appartient. Il était possible de stocker le dictionnaire, bien sûr, mais en premier lieu, il ne serait pas sérialisé par Unity, dans le second, il faudrait définir manuellement les numéros.S'il est difficile pour le joueur de terminer le niveau, il peut obtenir un indice - les valeurs correctes sur certaines entrées, ou l'élément correct dans le deuxième mode. Cela a également été fait manuellement, bien qu'il puisse être automatisé.Si l'aide du joueur n'a pas aidé, il peut sauter complètement le niveau. En cas de manque d'un niveau, le joueur obtient 1 étoile pour lui.Un utilisateur qui a passé un niveau avec un indice ne peut pas le réexécuter pendant un certain temps, de sorte qu'il serait difficile de réexécuter le niveau avec de la mémoire fraîche, comme sans indice.Monétisation
ContenuIl existe deux types de monétisation dans le jeu: l'affichage d'annonces et la désactivation d'annonces contre de l'argent. Un affichage d'annonce comprend l'affichage d'annonces entre les niveaux et l'affichage des annonces récompensées pour sauter un niveau.Si le joueur est prêt à payer pour désactiver la publicité, il peut le faire. Dans ce cas, les annonces entre les niveaux et lors du saut d'un niveau ne seront pas affichées.Pour la publicité, une classe appelée AdsService a été créée , avec une 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; } }
Ici, HelpAd est une annonce récompensée pour avoir sauté un niveau. Au départ, nous appelions aide une aide partielle et complète. Partiel est un indice, et complet est un niveau de saut.Cette classe contient à l'intérieur de la limitation de la fréquence d'affichage des publicités dans le temps, après le premier lancement du jeu.L'implémentation utilise le plug-in Google Mobile Ads Unity .Avec la publicité récompensée, je suis monté sur un râteau - il s'avère que les délégués fidèles peuvent être appelés dans un autre fil, il n'est pas très clair pourquoi. Par conséquent, il est préférable que ces délégués n'appellent rien dans le code lié à Unity. Si un achat a été effectué pour désactiver la publicité, la publicité ne sera pas affichée et le délégué exécutera immédiatement l'affichage réussi de la publicité.Il y a une interface pour faire du shopping public interface IPurchaseService { bool IsAdsDisablePurchased { get; } event Action DisableAdsPurchased; void BuyDisableAds(); void RemoveDisableAd(); }
Unity IAP est utilisé dans l'implémentation. Il y a une astuce pour acheter les déconnexions publicitaires. Google Play ne semble pas indiquer que le joueur a acheté un achat. Une simple confirmation viendra qu'elle est décédée une fois. Mais si vous mettez le statut du produit après l'achat non terminé mais en attente, cela vous permettra de vérifier la propriété du produit hasReceipt . Si c'est vrai, l'achat est terminé.Bien sûr, cela confond une telle approche. Je soupçonne que ce n'est peut-être pas tout à fait fluide.La méthode RemoveDisableAd est nécessaire au moment du test, elle supprime la panne publicitaire achetée.Interface utilisateur
ContenuTous les éléments d'interface fonctionnent selon une approche orientée événement. Les éléments d'interface eux-mêmes ne contiennent généralement pas de logique autre que les événements appelés par des méthodes publiques que Unity peut utiliser. Bien qu'il arrive également d'effectuer certaines tâches liées uniquement à l'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(); } }
En fait, ce n'est pas toujours le cas. Il est bon de laisser ces éléments en tant que vue active, d'en faire un écouteur d'événements, quelque chose comme un contrôleur qui déclenchera les actions nécessaires sur les gestionnaires.Analytique
ContenuSur le chemin de la moindre résistance, l'analytique Unity a été choisie . Facile à mettre en œuvre, bien que limité pour un abonnement gratuit - il est impossible d'exporter les données source. Il y a également une limite sur le nombre d'événements - 100 / heure par joueur.Pour l'analytique, créé la classe wrapper AnalyticsService . Il dispose de méthodes pour chaque type d'événement, reçoit les paramètres nécessaires et provoque l'envoi de l'événement à l'aide des outils intégrés à Unity. Créer une méthode pour chaque événement n'est certainement pas la meilleure pratique dans son ensemble, mais dans un petit projet en connaissance de cause, c'est mieux que de faire quelque chose de grand et de compliqué.Tous les événements utilisés sont CustomEvent.. Ils sont construits à partir du nom de l'événement et du nom et de la valeur du paramètre de dictionnaire. AnalyticsService obtient les valeurs requises à partir des paramètres et crée un dictionnaire à l'intérieur.Tous les noms et paramètres d'événement sont placés dans des constantes. Pas sous la forme d'une approche traditionnelle avec ScriptableObject, car ces valeurs ne devraient jamais changer.Exemple de méthode: 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} }); }
Positionnement et diagrammes de la caméra
Contenu Latâche consiste à placer FinalDevice en haut de l'écran, à la même distance de la bordure supérieure et les Sources en bas également toujours à égale distance de la bordure inférieure. De plus, les écrans sont disponibles dans différents rapports d'aspect, vous devez ajuster la taille de la caméra avant de commencer le niveau afin qu'elle s'adapte correctement au circuit.Pour ce faire, la classe CameraAlign est créée . Algorithme de taille:- Retrouvez tous les éléments nécessaires sur la scène
- Trouvez la largeur et la hauteur minimales en fonction du rapport hauteur / largeur
- Déterminer la taille de la caméra
- Placez l'appareil photo au centre
- Déplacez FinalDevice en haut de l'écran
- Déplacer les sources vers le bas de l'écran
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); } }
Cette méthode est appelée lorsque la scène démarre dans la classe wrapper.Jeux de couleurs
ContenuÉtant donné que le jeu aura une interface très primitive, j'ai décidé de le faire avec deux schémas de couleurs, noir et blanc.Pour ce faire, créé une interface public interface IColors { Color ColorAccent { get; } Color Background { get; set; } Color Foreground { get; set; } event Action ColorsChanged; }
Les couleurs peuvent être définies directement dans l'éditeur Unity; cela peut être utilisé pour les tests. Ensuite, ils peuvent être commutés et avoir deux jeux de couleurs.Les couleurs d'arrière-plan et d'avant-plan peuvent changer, un accent de couleur dans n'importe quel mode.Étant donné que le lecteur peut définir un thème non standard, les données de couleur doivent être stockées dans le fichier de paramètres. Si le fichier de paramètres ne contient pas de données de couleur, elles sont remplies de valeurs standard.Ensuite, il existe plusieurs classes: CameraColorAdjustment - responsable de la définition de la couleur d'arrière-plan sur la caméra, UiColorAdjustment - définition des couleurs des éléments d'interface et TextMeshColorAdjustment- définit la couleur des nombres sur les sources. UiColorAdjustment utilise également des balises. Dans l'éditeur, vous pouvez marquer chaque élément avec une balise qui indiquera pour quel type de couleur il doit être défini (Arrière-plan, Premier plan, AccentColor et FixedColor). Tout cela est défini au début de la scène ou en cas de changement de schéma de couleurs.Résultat:


Extensions de l'éditeur
ContenuPour simplifier et accélérer le processus de développement, il est souvent nécessaire de créer le bon outil, qui n'est pas fourni par les outils d'édition standard. L'approche traditionnelle dans Unity consiste à créer une classe descendante EditorWindow. Il existe également une approche avec UiElements, mais elle est toujours en cours de développement, j'ai donc décidé d'utiliser l'approche traditionnelle.Si vous créez simplement une classe qui utilise quelque chose de l'espace de noms UnityEditor à côté d'autres classes du jeu, le projet ne sera tout simplement pas assemblé, car cet espace de noms n'est pas disponible dans la build. Il existe plusieurs solutions:- Sélectionnez un projet distinct pour les scripts de l'éditeur
- Placer des fichiers dans le dossier Assets / Editor
- Enveloppez ces fichiers dans #if UNITY_EDITOR
Le projet utilise la première approche et parfois #si UNITY_EDITOR, si nécessaire, ajoutez une petite partie pour l'éditeur à la classe requise dans la génération.Toutes les classes qui sont nécessaires uniquement dans l'éditeur que j'ai défini dans l'assemblage, qui ne seront disponibles que dans l'éditeur. Elle n'ira pas à la construction du jeu.
Ce serait bien maintenant d'avoir DI dans vos extensions d'éditeur. Pour cela, j'utilise Zenject.StaticContext. Afin de le définir dans l'éditeur, une classe avec l'attribut InitializeOnLoad est utilisée, dans laquelle il existe un constructeur statique. [InitializeOnLoad] public class EditorInstaller { static EditorInstaller() { var container = StaticContext.Container; container.Bind<IElementsProvider>().To<ElementsProvider>().AsSingle(); container.Bind<ISolver>().To<Solver>().AsSingle(); .... } }
Pour enregistrer des classes ScriptableObject dans un contexte statique, j'utilise le code suivant: BindFirstScriptableObject<ISceneNameConfiguration, SceneNameConfiguration>(container); private static void BindFirstScriptableObject<TInterface, TImplementation>(DiContainer container) where TImplementation : ScriptableObject, TInterface { var obj = GetFirstScriptableObject<TImplementation>(); container.Bind<TInterface>().FromInstance(obj).AsSingle(); } private static T GetFirstScriptableObject<T>() where T : ScriptableObject { var guids = AssetDatabase.FindAssets("t:" + typeof(T).Name); string path = AssetDatabase.GUIDToAssetPath(guids.First()); var obj = AssetDatabase.LoadAssetAtPath<T>(path); return obj; }
TImplementation n'est requis que pour cette ligne AssetDatabase.LoadAssetAtPath (path)Il n'est pas possible de placer la dépendance dans le constructeur. Au lieu de cela, ajoutez l'attribut [Inject] aux champs de dépendance dans la classe window et appelezStaticContext.Container.Inject (this) au démarrage de la fenêtre ;Je recommande également d'ajouter au cycle de mise à jour de la fenêtre une vérification nulle de l'un des champs dépendants, et si le champ est vide, effectuez la ligne ci-dessus. Parce qu'après avoir modifié le code dans le projet, Unity peut recréer la fenêtre et ne pas appeler Awake dessus.Générateur
Contenu La
vue initiale du générateurLa fenêtre doit fournir une interface pour générer une liste de schémas avec des paramètres, afficher une liste de schémas et placer le schéma sélectionné sur la scène actuelle.La fenêtre se compose de trois sections de gauche à droite:- paramètres de génération
- liste d'options sous forme de boutons
- option sélectionnée comme texte
Les colonnes sont créées à l'aide de EditorGUILayout.BeginVertical () et EditorGUILayout.EndVertical (). Malheureusement, cela n'a pas fonctionné pour fixer et limiter les tailles, mais ce n'est pas si critique.Il s'est avéré que le processus de génération sur un grand nombre de circuits n'est pas si rapide. Beaucoup de combinaisons sont obtenues avec des éléments de I. Comme l'a montré le profileur, la partie la plus lente est le circuit lui-même. La parallélisation n'est pas une option; toutes les options utilisent un schéma, mais il est difficile de cloner cette structure.Ensuite, j'ai pensé que probablement tout le code des extensions de l'éditeur fonctionne en mode débogage. Sous Release, le débogage ne fonctionne pas si bien, les points d'arrêt ne s'arrêtent pas, les lignes sont sautées, etc. En effet, après avoir mesuré les performances, il s'est avéré que la vitesse du générateur dans Unity correspond à l'assemblage Debug lancé à partir de l'application console, qui est ~ 6 fois plus lent que Release. Gardez cela à l'esprit.
Alternativement, vous pouvez créer un assembly externe et l'ajouter à la DLL Unity avec l'assembly, mais cela complique considérablement l'assembly et la modification du projet.Immédiatement apporté le processus de génération dans une tâche distincte avec du code contenant ceci:circuitGenerator.Generate (lignes, maxElementsInLine, availableLogicalElements, useNOT, modification) .ToList ()Déjà mieux, l'éditeur ne se bloque pas au moment de la génération. Mais il faut encore attendre longtemps, plusieurs minutes (plus de 20 minutes sur des circuits de grande taille). De plus, il y avait un problème que la tâche n'est pas si facile à terminer et qu'elle continue de fonctionner jusqu'à la fin de la génération.Beaucoup de code internal static class Ext { public static IEnumerable<CircuitVariant> OrderVariants(this IEnumerable<CircuitVariant> circuitVariants) { return circuitVariants.OrderBy(a => a.Solutions.Count()) .ThenByDescending(a => a.Solutions .Select(b => b.Sum(i => i ? 1 : -1)) .OrderByDescending(b=>b) .First()); } } public interface IEditorGenerator : IDisposable { CircuitVariant[] FilteredVariants { get; } int LastPage { get; } void FilterVariants(int page); void Start(int lines, int maxElementsInLine, ICollection<int> availableGates, bool useNOT, StructureModification? modification, int maxSolutions); void Stop(); void Fetch(); } public class EditorGenerator : IEditorGenerator { private const int PageSize = 100; private readonly ICircuitGenerator circuitGenerator; private ConcurrentBag<CircuitVariant> variants; private List<CircuitVariant> sortedVariants; private Thread generatingThread; public EditorGenerator(ICircuitGenerator circuitGenerator) { this.circuitGenerator = circuitGenerator; } public void Dispose() { generatingThread?.Abort(); } public CircuitVariant[] FilteredVariants { get; private set; } public int LastPage { get; private set; } public void FilterVariants(int page) { CheckVariants(); if (sortedVariants == null) { Fetch(); } FilteredVariants = sortedVariants.Skip(page * PageSize) .Take(PageSize) .ToArray(); int count = sortedVariants.Count; LastPage = count % PageSize == 0 ? (count / PageSize) - 1 : count / PageSize; } public void Fetch() { CheckVariants(); sortedVariants = variants.OrderVariants() .ToList(); } public void Start(int lines, int maxElementsInLine, ICollection<int> availableGates, bool useNOT, StructureModification? modification, int maxSolutions) { if (generatingThread != null) { Stop(); } variants = new ConcurrentBag<CircuitVariant>(); generatingThread = new Thread(() => { var v = circuitGenerator.Generate(lines, maxElementsInLine, availableGates, useNOT, modification, maxSolutions); foreach (var item in v) { variants.Add(item); } }); generatingThread.Start(); } public void Stop() { generatingThread?.Abort(); sortedVariants = null; variants = null; generatingThread = null; FilteredVariants = null; } private void CheckVariants() { if (variants == null) { throw new InvalidOperationException("VariantsGeneration is not started. Use Start before."); } } ~EditorGenerator() { generatingThread.Abort(); } }
L'idée est que l'arrière-plan doit être généré et, sur demande, la liste interne des options triées sera mise à jour. Ensuite, vous pouvez page par page pour sélectionner les options. Ainsi, il n'est pas nécessaire de trier à chaque fois, ce qui accélère considérablement le travail sur les grandes listes. Les schémas sont triés par «intérêt»: par le nombre de solutions, par augmentation et par la façon dont différentes valeurs sont requises pour la solution. Autrement dit, un circuit avec une solution de 1 1 1 1 est moins intéressant que 1 0 1 1.
Ainsi, il s'est avéré, sans attendre la fin de la génération, déjà sélectionner un circuit pour le niveau. Un autre avantage est qu'en raison de la pagination, l'éditeur ne ralentit pas comme le bétail.La fonctionnalité Unity est très dérangeante dans la mesure où lorsque vous cliquez sur Lecture, le contenu de la fenêtre est réinitialisé, comme toutes les données générées. S'ils étaient facilement sérialisables, ils pouvaient être stockés sous forme de fichiers. De cette façon, vous pouvez même mettre en cache les résultats de la génération. Mais hélas, sérialiser une structure complexe où les objets se réfèrent les uns aux autres est difficile.De plus, j'ai ajouté des lignes à chaque porte, comme if (Input.Length == 2) { return Input[0].Value && Input[1].Value; }
Ce qui a grandement amélioré les performances.Solveur
ContenuLorsque vous assemblez un circuit dans l'éditeur, vous devez être en mesure de comprendre rapidement s'il est en cours de résolution et combien de solutions il a. Pour ce faire, j'ai créé une fenêtre «solveur». Il apporte des solutions au schéma actuel sous forme de texte
La logique de son «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); }
Utile
Table des matièresAsserthelp
ContenuPour vérifier que les valeurs sont définies dans les actifs, j'utilise des méthodes d'extension que j'appelle dans 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 vérification de la capacité de chargement de la scène peut parfois échouer, bien que la scène puisse être chargée. C'est peut-être un bogue dans Unity.Exemples d'utilisation: mainMenuSceneName.AssertNOTEmpty(nameof(mainMenuSceneName)).AssertSceneCanBeLoaded(); levelNamePrefix.AssertNOTEmpty(nameof(levelNamePrefix)); editorElementsPrefabs.AssertNOTNull(); not.AssertType(ElectricalElementType.NOT);
SceneObjectsHelper
ContenuPour travailler avec des éléments de scène, la classe SceneObjectsHelper était également utile:Beaucoup de code namespace Circuit.Game.Utility { public interface ISceneObjectsHelper { T[] FindObjectsOfType<T>(bool includeDisabled = false) where T : class; T FindObjectOfType<T>(bool includeDisabled = false) where T : class; T Instantiate<T>(T prefab) where T : Object; void DestroyObjectsOfType<T>(bool includeDisabled = false, bool immediate = false) where T : class; void Destroy<T>(T obj, bool immediate = false) where T : Object; void DestroyAllChildren(Transform transform); void Inject(object obj); T GetComponent<T>(GameObject obj) where T : class; } public class SceneObjectsHelper : ISceneObjectsHelper { private readonly DiContainer diContainer; public SceneObjectsHelper(DiContainer diContainer) { this.diContainer = diContainer; } public T GetComponent<T>(GameObject obj) where T : class { return obj.GetComponents<Component>().OfType<T>().FirstOrDefault(); } public T[] FindObjectsOfType<T>(bool includeDisabled = false) where T : class { if (includeDisabled) { return Resources.FindObjectsOfTypeAll(typeof(Object)).OfType<T>().ToArray(); } return Object.FindObjectsOfType<Component>().OfType<T>().ToArray(); } public void DestroyObjectsOfType<T>(bool includeDisabled = false, bool immediate = false) where T : class { var objects = includeDisabled ? Resources.FindObjectsOfTypeAll(typeof(Object)).OfType<T>().ToArray() : Object.FindObjectsOfType<Component>().OfType<T>().ToArray(); foreach (var item in objects) { if (immediate) { Object.DestroyImmediate((item as Component)?.gameObject); } else { Object.Destroy((item as Component)?.gameObject); } } } public void Destroy<T>(T obj, bool immediate = false) where T : Object { if (immediate) { Object.DestroyImmediate(obj); } else { Object.Destroy(obj); } } public void DestroyAllChildren(Transform transform) { int childCount = transform.childCount; for (int i = 0; i < childCount; i++) { Destroy(transform.GetChild(i).gameObject); } } public T FindObjectOfType<T>(bool includeDisabled = false) where T : class { if (includeDisabled) { return Resources.FindObjectsOfTypeAll(typeof(Object)).OfType<T>().FirstOrDefault(); } return Object.FindObjectsOfType<Component>().OfType<T>().FirstOrDefault(); } public void Inject(object obj) { diContainer.Inject(obj); } public T Instantiate<T>(T prefab) where T : Object { var obj = Object.Instantiate(prefab); if (obj is Component) { var components = ((Component) (object) obj).gameObject.GetComponents<Component>(); foreach (var component in components) { Inject(component); } } else { Inject(obj); } return obj; } } }
Ici, certaines choses peuvent ne pas être très efficaces là où des performances élevées sont nécessaires, mais elles sont rarement sollicitées pour moi et ne créent aucune influence. Mais ils vous permettent de trouver des objets par l'interface, par exemple, ce qui est plutôt joli.Coroutinestarter
ContenuLancer Coroutine ne peut que MonoBehaviour. J'ai donc créé la classe CoroutineStarter et l'ai enregistrée dans le contexte de la scène. public interface ICoroutineStarter { void BeginCoroutine(IEnumerator routine); } public class CoroutineStarter : MonoBehaviour, ICoroutineStarter { public void BeginCoroutine(IEnumerator routine) { StartCoroutine(routine); } }
En plus de la commodité, l'introduction de tels outils a facilité le test automatique. Par exemple, l'exécution de coroutine dans les tests: coroutineStarter.When(x => x.BeginCoroutine(Arg.Any<IEnumerator>())).Do(info => { var a = (IEnumerator) info[0]; while (a.MoveNext()) { } });
Gizmo
ContenuPour faciliter l'affichage des éléments invisibles, je recommande d'utiliser des images de gizmo qui ne sont visibles que dans la scène. Ils facilitent la sélection d'un élément invisible en un clic. Également fait des connexions d'éléments sous forme de lignes: private void OnDrawGizmos() { if (outputConnectorMb != null) { Handles.DrawLine(transform.position, outputConnectorMb.Transform.position); } }

Test
ContenuJe voulais tirer le meilleur parti des tests automatiques, car les tests étaient utilisés dans la mesure du possible et faciles à utiliser.Pour les tests unitaires, il est habituel d'utiliser des objets fictifs au lieu des classes implémentant l'interface dont dépend la classe de test. Pour cela, j'ai utilisé la bibliothèque NSubstitute . Ce qui est très content.Unity ne prend pas en charge NuGet, j'ai donc dû obtenir la DLL séparément, puis l'assembly, car une dépendance est ajoutée au fichier AssemblyDefinition et est utilisée sans problème.
Pour les tests automatiques, Unity propose TestRunner, qui fonctionne avec le framework de test NUnit très populaire . Du point de vue de TestRunner, il existe deux types de tests:- EditMode — , . Nunit . , . GameObject Monobehaviour . , EditMode .
- PlayMode — .
EditMode D'après mon expérience, il y a eu beaucoup d'inconvénients et de comportements étranges dans ce mode. Néanmoins, ils sont pratiques pour vérifier automatiquement la santé de l'application dans son ensemble. Ils fournissent également une vérification honnête du code dans des méthodes telles que Démarrer, Mettre à jour, etc.Les tests PlayMode peuvent être décrits comme des tests NUnit normaux, mais il existe une alternative. Dans PlayMode, vous devrez peut-être attendre un certain temps ou un certain nombre d'images. Pour ce faire, les tests doivent être décrits d'une manière similaire à Coroutine. La valeur retournée doit être IEnumerator / IEnumerable et à l'intérieur, pour sauter l'heure, vous devez utiliser, par exemple: yield return null;
ou
yield return new WaitForSeconds(1);
Il existe d'autres valeurs de retour.Un tel test doit définir l'attribut UnityTest . Il existe également desattributs UnitySetUp et UnityTearDown avec lesquels vous devez utiliser une approche similaire.Je partage à mon tour les tests EditMode pour le module et l'intégration.Les tests unitaires ne testent qu'une seule classe, complètement isolée des autres classes. Ces tests facilitent souvent la préparation de l'environnement pour la classe testée et les erreurs, une fois réussies, vous permettent de localiser plus précisément le problème.Dans les tests unitaires, je teste de nombreuses classes de base et classes nécessaires directement dans le jeu.Les tests des éléments de circuit sont très similaires, j'ai donc créé une classe de 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) {
D'autres tests d'élément ressemblent à ceci: 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); } }
C'est peut-être une complication en termes de facilité de compréhension, ce qui n'est généralement pas nécessaire dans les tests, mais je ne voulais pas copier-coller la même chose 11 fois.Il existe également des tests de GameManagers. Puisqu'ils ont beaucoup en commun, ils ont également obtenu une classe de base de tests. Les gestionnaires de jeux dans les deux modes devraient avoir des fonctionnalités identiques et différentes. Les choses générales sont testées avec les mêmes tests pour chaque successeur et un comportement spécifique est testé en plus. Malgré l'approche événementielle, il n'a pas été difficile de tester le comportement réalisé par l'événement: [Test] public void FullHelpAgree_FinishLevel() {
Dans les tests d'intégration, j'ai également testé des classes pour l'éditeur et les ai prises dans le contexte statique du conteneur DI. Ainsi, la vérification inclut l'injection correcte, qui n'est pas moins importante que le test unitaire. 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); } }
Ce test utilise des implémentations réelles de toutes les dépendances et définit également des objets sur la scène, ce qui est tout à fait possible dans les tests EditMode. Il est vrai de tester qu'il les a mis en état d'esprit - je ne sais pas trop comment, donc je vérifie que le circuit affiché a des solutions.En intégration, il existe également des tests pour CircuitGenerator (StructureGenerator + VariantsGenerator) et 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() {
Les tests PlayMode sont utilisés comme tests système. Ils vérifient les préfabriqués, l'injection, etc. Une bonne option consiste à utiliser des scènes prêtes à l'emploi dans lesquelles le test se charge uniquement et produit des interactions. Mais j'utilise une scène vide préparée pour les tests, dans laquelle l'environnement est différent de ce qui sera dans le jeu. Il y a eu une tentative d'utiliser PlayMode pour tester l'ensemble du processus de jeu, comme entrer dans le menu, entrer dans le niveau, etc., mais le travail de ces tests s'est avéré instable, il a donc été décidé de le reporter pour plus tard (jamais).Il est pratique d'utiliser des outils d'évaluation de la couverture pour écrire des tests, mais je n'ai malheureusement trouvé aucune solution fonctionnant avec Unity.J'ai trouvé un problème avec la mise à niveau d'Unity vers 2018.3, les tests ont commencé à fonctionner beaucoup plus lentement, jusqu'à 10 fois plus lentement (dans un exemple synthétique). Le projet contient 288 tests EditMode qui s'exécutent pendant 11 secondes, bien que rien n'y ait été fait depuis si longtemps.Résumé du développement
Contenu
Capture d'écran du niveau de jeu Lalogique de certains jeux peut être formulée quelle que soit la plateforme. À un stade précoce, cela facilite le développement et la testabilité par autotests.DI est pratique. Même en tenant compte du fait que Unity ne l'a pas nativement, le vissé sur le côté fonctionne assez bien.Unity vous permet de tester automatiquement un projet. Certes, car tous les composants GameObject intégrés n'ont pas d'interface et ne peuvent être utilisés directement que pour se moquer de choses comme Collider, SpriteRenderer, MeshRenderer, etc. ne fonctionnera pas. Bien que GetComponent vous permet d'obtenir des composants sur l'interface. En option, écrivez vos propres emballages pour tout.L'utilisation d'autotests a simplifié le processus de génération de la logique initiale, alors qu'il n'y avait pas d'interface utilisateur avec le code. Plusieurs tests ont trouvé une erreur immédiatement pendant le développement. Naturellement, les erreurs sont apparues plus loin, mais il était souvent possible d'écrire des tests supplémentaires / de modifier les tests existants, puis de les intercepter automatiquement. Erreurs avec DI, préfabriqués, objets scriptables et similaires, les tests sont difficiles à détecter, mais c'est possible, car vous pouvez utiliser de vrais installateurs pour Zenject, ce qui resserrera les dépendances, comme cela se produit dans la génération.Unity génère une énorme quantité d'erreurs, de plantages. Souvent, les erreurs sont résolues en redémarrant l'éditeur. Face à une étrange perte de références aux objets dans les préfabriqués. Parfois, le préfabriqué par référence est détruit (ToString () renvoie «null»), bien que tout semble fonctionner, le préfabriqué est glissé sur la scène et le lien n'est pas vide. Parfois, certaines connexions sont perdues dans toutes les scènes. Tout semble installé, cela a fonctionné, mais lors du passage à une autre branche, toutes les scènes sont cassées - il n'y a pas de liens entre les éléments.Heureusement, ces erreurs ont souvent été corrigées en redémarrant l'éditeur ou en supprimant parfois le dossier Bibliothèque.Au total, environ six mois sont passés de l'idée à la publication sur Google Play. Le développement lui-même a pris environ 3 mois, en temps libre par rapport aux travaux principaux.