Machen Sie es wahr - Entwickeln Sie ein Logikspiel für Unity



Ich möchte den Entwicklungsprozess eines einfachen Handyspiels von zwei Entwicklern und einem Künstler teilen. Dieser Artikel beschreibt weitgehend die technische Implementierung.
Achtung, viel Text!

Der Artikel ist keine Anleitung oder Lektion, obwohl ich hoffe, dass die Leser etwas Nützliches daraus lernen können. Entwickelt für Entwickler, die mit Unity vertraut sind und über Programmiererfahrung verfügen.

Inhalt:


Idee
Gameplay
Handlung
Entwicklung
Kern
  1. Elektrische Elemente
  2. Löser
  3. ElementsProvider
  4. CircuitGenerator

Spielklassen

  1. Entwicklungsansatz und DI
  2. Konfiguration
  3. Elektrische Elemente
  4. Spielverwaltung
  5. Level Laden
  6. Zwischensequenzen
  7. Zusätzliches Gameplay
  8. Monetarisierung
  9. Benutzeroberfläche
  10. Analytik
  11. Kamerapositionierung und Diagramme
  12. Farbschemata

Editor-Erweiterungen


  1. Generator
  2. Löser

Nützlich

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

Testen
Entwicklungszusammenfassung

Idee


Inhalt

Es gab die Idee, in kurzer Zeit ein einfaches Handyspiel zu machen.

Bedingungen:

  • Einfach zu implementierendes Spiel
  • Mindestanforderungen an die Kunst
  • Kurze Entwicklungszeit (mehrere Monate)
  • Mit einfacher Automatisierung der Erstellung von Inhalten (Levels, Orte, Spielelemente)
  • Erstelle schnell ein Level, wenn das Spiel aus einer endlichen Anzahl von Levels besteht

Um zu entscheiden, aber was eigentlich? Schließlich kam die Idee auf, ein Spiel zu machen, nicht die Idee eines Spiels. Es wurde beschlossen, sich vom App Store inspirieren zu lassen.

Zu den oben genannten Elementen werden hinzugefügt:

  • Das Spiel sollte eine gewisse Beliebtheit bei den Spielern haben (Anzahl der Downloads + Bewertungen)
  • Der App Store sollte nicht mit ähnlichen Spielen überfüllt sein

Es wurde ein Spiel gefunden, dessen Gameplay auf logischen Toren basiert. Es gab keine ähnlichen in großer Anzahl. Das Spiel hat viele Downloads und positive Bewertungen. Trotzdem gab es einige Nachteile, die in Ihrem Spiel berücksichtigt werden können.

Das Gameplay des Spiels ist, dass der Level eine digitale Schaltung mit vielen Ein- und Ausgängen ist. Der Spieler muss eine Kombination von Eingängen auswählen, damit der Ausgang logisch ist. 1. Es klingt nicht sehr schwierig. Das Spiel hat auch automatisch generierte Levels, was darauf hindeutet, dass die Erstellung von Levels automatisiert werden kann, obwohl dies nicht sehr einfach klingt. Das Spiel ist auch gut zum Lernen, was mir sehr gut gefallen hat.

Vorteile:

  • Technische Einfachheit des Gameplays
  • Sieht mit Autotests einfach zu testen aus
  • Fähigkeit, Level automatisch zu generieren

Nachteile:

  • Sie müssen zuerst Ebenen erstellen

Erforschen Sie nun die Fehler des Spiels, die Sie inspiriert haben.

  • Nicht an das benutzerdefinierte Seitenverhältnis angepasst, z. B. 18: 9
  • Es gibt keine Möglichkeit, ein schwieriges Level zu überspringen oder einen Hinweis zu erhalten
  • In den Bewertungen gab es Beschwerden über eine kleine Anzahl von Ebenen
  • Die Bewertungen beschwerten sich über die mangelnde Vielfalt der Elemente

Wir fahren mit der Planung unseres Spiels fort:

  • Wir verwenden Standard-Logikgatter (AND, NAND, OR, NOR, XOR, XNOR, NOR, NOT).
  • Tore werden mit einem Bild anstelle einer Textbezeichnung angezeigt, die leichter zu unterscheiden ist. Da Elemente eine Standard-ANSI-Notation haben, verwenden wir sie.
  • Wir verwerfen den Schalter, der einen Eingang mit einem der Ausgänge verbindet. Aufgrund der Tatsache, dass Sie auf sich selbst klicken müssen und nicht ein wenig in die realen digitalen Elemente passen. Ja, und ein Kippschalter in einem Chip ist kaum vorstellbar.
  • Fügen Sie die Elemente des Codierers und Decodierers hinzu.
  • Wir führen einen Modus ein, in dem der Spieler das gewünschte Element in der Zelle mit festen Werten an den Eingängen der Schaltung auswählen muss.
  • Wir bieten dem Spieler Hilfe: Hinweis + Überspringstufe.
  • Es wäre schön, eine Handlung hinzuzufügen.

Gameplay


Inhalt

Modus 1: Der Spieler erhält eine Schaltung und kann die Werte an den Eingängen ändern.
Modus 2: Der Spieler erhält eine Schaltung, in der er die Elemente ändern kann, aber die Werte an den Eingängen nicht ändern kann.

Das Gameplay wird in Form von vorbereiteten Levels sein. Nach Abschluss des Levels muss der Spieler ein Ergebnis erzielen. Dies erfolgt in Form der traditionellen drei Sterne, abhängig vom Ergebnis der Passage.

Was können die Leistungsindikatoren sein:
Anzahl der Aktionen: Jede Interaktion mit Spielelementen erhöht den Zähler.
Die Anzahl der Unterschiede im resultierenden Zustand zum Original. Berücksichtigt nicht, wie viele Versuche der Spieler ausführen musste. Leider passt es nicht zum zweiten Regime.
Es wäre schön, den gleichen Modus mit zufälliger Level-Generierung hinzuzufügen. Aber vorerst, verschieben Sie es für später.

Handlung


Inhalt

Während ich über das Gameplay nachdachte und mit der Entwicklung begann, schienen verschiedene Ideen das Spiel zu verbessern. Und es erschien eine interessante Idee - eine Handlung hinzuzufügen.

Es geht um einen Ingenieur, der Schaltungen entwirft. Nicht schlecht, aber nicht vollständig. Vielleicht lohnt es sich, die Herstellung von Chips basierend auf den Aktivitäten des Spielers anzuzeigen? Irgendwie Routine gibt es kein verständliches und einfaches Ergebnis.

Die Idee! Ein Ingenieur entwickelt mit seinen Logikschaltungen einen coolen Roboter. Der Roboter ist ziemlich einfach verständlich und passt perfekt zum Gameplay.

Erinnern Sie sich an den ersten Absatz „Mindestanforderungen an Kunst“? Etwas passt nicht zu den Zwischensequenzen in der Handlung. Dann kommt ein bekannter Künstler zur Rettung, der sich bereit erklärt, uns zu helfen.

Nun entscheiden wir uns für das Format und die Integration von Zwischensequenzen in das Spiel.

Das Diagramm muss als Zwischensequenz ohne Bewertung oder als Textbeschreibung angezeigt werden, um Lokalisierungsprobleme zu beseitigen, das Verständnis zu vereinfachen und viele auf Mobilgeräten ohne Ton abzuspielen. Das Spiel ist ein sehr reales Element digitaler Schaltkreise, das heißt, es ist durchaus möglich, dies mit der Realität zu verbinden.

Zwischensequenzen und Ebenen sollten separate Szenen sein. Vor einem bestimmten Level wird eine bestimmte Szene geladen.

Nun, die Aufgabe ist festgelegt, es gibt Ressourcen zu erfüllen, die Arbeit hat begonnen zu kochen.

Entwicklung


Inhalt

Ich habe mich sofort für die Plattform entschieden, das ist Unity. Ja, ein bisschen übertrieben, aber ich kenne sie trotzdem.

Während der Entwicklung wird der Code sofort mit Tests oder sogar danach geschrieben. Für eine ganzheitliche Darstellung werden die Tests jedoch in einem separaten Abschnitt unten aufgeführt. Im aktuellen Abschnitt wird der Entwicklungsprozess getrennt vom Testen beschrieben.

Kern


Inhalt

Der Kern des Gameplays sieht ziemlich einfach aus und ist nicht an die Engine gebunden. Deshalb haben wir mit dem Design in Form von C # -Code begonnen. Es scheint, dass Sie eine separate Kern-Kern-Logik auswählen können. Nehmen Sie es zu einem separaten Projekt heraus.

Unity arbeitet mit einer C # -Lösung und Projekte im Inneren sind für einen normalen .NET-Entwickler etwas ungewöhnlich. SLN- und CSsproj-Dateien werden von Unity selbst generiert, und Änderungen in diesen Dateien werden auf der Unity-Seite nicht berücksichtigt. Er wird sie einfach überschreiben und alle Änderungen löschen. Um ein neues Projekt zu erstellen, müssen Sie die Assembly Definition- Datei verwenden.





Unity generiert jetzt ein Projekt mit dem entsprechenden Namen. Alles, was sich in dem Ordner mit der .asmdef-Datei befindet, bezieht sich auf dieses Projekt und diese Assembly.

Elektrische Elemente


Inhalt

Die Aufgabe besteht darin, im Code die Interaktion logischer Elemente miteinander zu beschreiben.

  • Ein Element kann mehrere Eingänge und mehrere Ausgänge haben.
  • Der Eingang des Elements muss mit dem Ausgang eines anderen Elements verbunden sein
  • Das Element selbst muss eine eigene Logik enthalten.

Fangen wir an.

  • Das Element enthält eine eigene Funktionslogik und Verknüpfungen zu seinen Eingängen. Wenn Sie einen Wert von einem Element anfordern, werden Werte von den Eingaben übernommen, auf diese logisch angewendet und das Ergebnis zurückgegeben. Es können mehrere Ausgänge vorhanden sein, daher wird der Wert für einen bestimmten Ausgang angefordert. Der Standardwert ist 0.
  • Um Werte am Eingang zu erfassen, gibt es einen Eingangsanschluss p, in dem eine Verknüpfung zu einem anderen - dem Ausgangsanschluss - gespeichert ist.
  • Der Ausgabekonnektor verweist auf ein bestimmtes Element und speichert eine Verknüpfung zu seinem Element. Wenn er einen Wert anfordert, fordert er ihn vom Element an.



Die Pfeile geben die Richtung der Daten an, die Abhängigkeit der Elemente in die entgegengesetzte Richtung.
Definieren Sie die Schnittstelle des Anschlusses. Sie können den Wert daraus erhalten.

public interface IConnector { bool Value { get; } } 

Wie kann man es an einen anderen Stecker anschließen?

Definieren Sie weitere Schnittstellen.

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

IInputConnector ist ein Eingangsconnector und hat eine Verbindung zu einem anderen Connector.

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

Der Ausgangsanschluss bezieht sich auf sein Element, von dem er einen Wert anfordert.

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

Das elektrische Element muss eine Methode enthalten, die einen Wert für einen bestimmten Ausgang zurückgibt. Number ist die Nummer des Ausgangs.

Ich habe es IElectricalElement genannt, obwohl es nur logische Spannungspegel überträgt, aber andererseits kann es ein Element sein, das überhaupt keine Logik hinzufügt, sondern nur einen Wert vermittelt, wie ein Leiter.

Fahren wir nun mit der Implementierung fort

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

Der eingehende Connector ist möglicherweise nicht verbunden. In diesem Fall wird false zurückgegeben.

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

Die Ausgabe sollte einen Link zu ihrem Element und seiner Nummer in Bezug auf das Element haben.
Außerdem fordert er unter Verwendung dieser Nummer einen Wert vom Element an.

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

Die Basisklasse für alle Elemente enthält nur ein Array von Eingaben.

Beispielimplementierung eines Elements:

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

Die Implementierung basiert vollständig auf logischen Operationen ohne eine harte Wahrheitstabelle. Vielleicht nicht so explizit wie bei der Tabelle, aber sie ist flexibel und funktioniert mit einer beliebigen Anzahl von Eingaben.
Alle Logikgatter haben einen Ausgang, sodass der Wert am Ausgang nicht von der Eingangsnummer abhängt.

Invertierte Elemente werden wie folgt hergestellt:

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

Es ist erwähnenswert, dass hier die GetValue-Methode überschrieben und nicht virtuell überschrieben wird. Dies geschieht auf der Grundlage der Logik, dass Nand sich weiterhin wie Und verhält, wenn er in Und speichert. Es war auch möglich, die Komposition anzuwenden, dies würde jedoch zusätzlichen Code erfordern, was wenig Sinn macht.

Neben herkömmlichen Ventilen wurden folgende Elemente erstellt:
Quelle - eine Quelle mit konstantem Wert von 0 oder 1.
Leiter - genau der gleiche oder Leiter, hat nur eine etwas andere Anwendung, siehe Generation.
AlwaysFalse - gibt immer 0 zurück, was für den zweiten Modus benötigt wird.

Löser


Inhalt

Als nächstes ist eine Klasse nützlich, um automatisch Kombinationen zu finden, die am Ausgang der Schaltung 1 ergeben.

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

Die Lösungen sind Brute Force. Hierzu wird die maximale Anzahl bestimmt, die durch einen Satz von Bits in einer Menge ausgedrückt werden kann, die der Anzahl von Quellen entspricht. Das heißt, 4 Quellen = 4 Bits = maximale Zahl 15. Wir sortieren alle Zahlen von 0 bis 15.

ElementsProvider


Inhalt

Zur Vereinfachung der Generierung habe ich beschlossen, für jedes Element eine Nummer zu definieren. Dazu habe ich die ElementsProvider-Klasse mit der IElementsProvider-Schnittstelle erstellt.

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

Die ersten beiden Listen sind so etwas wie Fabriken, die einen Artikel mit der angegebenen Nummer angeben. Die letzten beiden Listen sind eine Krücke, die aufgrund der Funktionen von Unity verwendet werden muss. Darüber weiter.

CircuitGenerator


Inhalt

Der schwierigste Teil der Entwicklung ist nun die Schaltungserzeugung.

Die Aufgabe besteht darin, eine Liste von Schemata zu erstellen, aus denen Sie im Editor das gewünschte auswählen können. Die Erzeugung wird nur für einfache Ventile benötigt.

Bestimmte Parameter des Schemas werden festgelegt. Dies sind: die Anzahl der Ebenen (horizontale Linien von Elementen) und die maximale Anzahl von Elementen in der Ebene. Sie müssen auch bestimmen, aus welchen Gates Sie Schaltungen erzeugen müssen.

Mein Ansatz war es, die Aufgabe in zwei Teile aufzuteilen - Strukturgenerierung und Auswahl von Optionen.

Der Strukturgenerator bestimmt die Positionen und Verbindungen von Logikelementen.
Der Variantengenerator wählt gültige Kombinationen von Elementen in Positionen aus.

Strukturgenerator


Die Struktur besteht aus Schichten von Logikelementen und Schichten von Leitern / Wechselrichtern. Die gesamte Struktur enthält keine realen Elemente, sondern Container für diese.

Der Container ist eine von IElectricalElement geerbte Klasse, die eine Liste gültiger Elemente enthält und zwischen diesen wechseln kann. Jeder Artikel hat eine eigene Nummer in der Liste.

 ElectricalElementContainer : ElectricalElementBase, IElectricalElement 


Ein Container kann "sich" auf eines der Elemente aus der Liste setzen. Während der Initialisierung müssen Sie eine Liste der Delegierten angeben, die die Elemente erstellen. Im Inneren ruft es jeden Delegierten an und erhält den Artikel. Dann können Sie den spezifischen Typ dieses Elements festlegen. Dadurch wird das interne Element mit denselben Eingaben wie im Container verbunden, und die Ausgabe aus dem Container wird aus der Ausgabe dieses Elements übernommen.



Methode zum Festlegen der Liste der Elemente:

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

Als nächstes können Sie den Typ folgendermaßen einstellen:

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

Danach funktioniert es als angegebenes Element.

Die folgende Struktur wurde für die Schaltung erstellt:

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

Wörterbücher speichern hier die Ebenennummer im Schlüssel und eine Reihe von Containern für diese Ebene. Als nächstes folgt eine Reihe von Quellen und ein FinalDevice, mit dem alles verbunden ist.

Somit erstellt der Strukturgenerator Container und verbindet sie miteinander. Dies alles wird in Ebenen von unten nach oben erstellt. Der Boden ist am breitesten (die meisten Elemente). Die darüber liegende Ebene enthält zweimal weniger Elemente usw., bis wir ein Minimum erreichen. Die Ausgänge aller Elemente der obersten Schicht sind mit dem Endgerät verbunden.

Die Logikelementschicht enthält Container für Gates. In der Leiterschicht gibt es Elemente mit einem Ein- und Ausgang. Elemente dort können entweder ein Leiter oder ein NO-Element sein. Der Leiter geht zum Ausgang, was zum Eingang kam, und das NO-Element gibt den invertierten Wert am Ausgang zurück.

Der erste, der ein Array von Quellen erstellt. Die Erzeugung erfolgt von unten nach oben, zuerst wird die Leiterschicht erzeugt, dann die Logikschicht und am Ausgang wieder Leiter.



Aber solche Pläne sind sehr langweilig! Wir wollten unser Leben noch mehr vereinfachen und beschlossen, die erzeugten Strukturen interessanter (komplexer) zu machen. Es wurde beschlossen, Strukturmodifikationen mit Verzweigung oder Verbindung durch viele Schichten hinzuzufügen.

Um „vereinfacht“ zu sagen - das bedeutet, dass Sie Ihr Leben in etwas anderem komplizieren müssen.
Das Erzeugen von Schaltkreisen mit maximaler Modifizierbarkeit erwies sich als mühsame und nicht ganz praktische Aufgabe. Aus diesem Grund hat unser Team beschlossen, die folgenden Kriterien zu erfüllen:
Die Entwicklung dieser Aufgabe dauerte nicht lange.
Mehr oder weniger adäquate Erzeugung modifizierter Strukturen.
Es gab keine Schnittpunkte zwischen den Leitern.
Aufgrund einer langen und harten Programmierung wurde die Lösung um 16 Uhr geschrieben.
Werfen wir einen Blick auf den Code und ̶̶̶̶̶̶̶̶̶̶.

Hier wird die OverflowArray-Klasse angetroffen. Aus historischen Gründen wurde es nach der grundlegenden Strukturgenerierung hinzugefügt und hat mehr mit der Variantengenerierung zu tun, daher befindet es sich unten. 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; } } // Clone CircuitStructure var structure = (CircuitStructure) baseStructure.Clone(); ConfigureInputs(lines, structure.Conductors, structure.Gates); var sources = AddSourcesLayer(structure.Conductors, maxElementsInLine); var finalElement = AddFinalElement(structure.Conductors); structure.Sources = sources; structure.FinalDevice = finalElement; int key = (i * 2) + 1; ModifyStructure(structure, elementArray, key, modification); ClearStructure(structure); yield return structure; } } } 

Nachdem ich diesen Code angesehen habe, möchte ich verstehen, was darin passiert.
Keine Sorge! Eine kurze Erklärung ohne Details eilt zu Ihnen.

Als erstes erstellen wir eine gewöhnliche (Basis-) Struktur.

 var baseStructure = GenerateStructure(lines, maxElementsInLine); 

Als Ergebnis einer einfachen Überprüfung setzen wir das Verzweigungszeichen (branchingSign) auf den entsprechenden Wert. Warum ist dies erforderlich? Weiter wird es klar sein.

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

Jetzt bestimmen wir die Länge unseres OverflowArray und initialisieren es.

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

Damit wir unsere Manipulationen mit der Struktur fortsetzen können, müssen wir die Anzahl der möglichen Variationen unseres OverflowArray herausfinden. Zu diesem Zweck wurde in der nächsten Zeile eine Formel angewendet.

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

Als nächstes folgt eine verschachtelte Schleife, in der die gesamte „Magie“ stattfindet und für die es all dieses Vorwort gab. Ganz am Anfang erhöhen wir die Werte unseres Arrays.

 elementArray.Increase(); 

Danach sehen wir eine Validierungsprüfung, als Ergebnis gehen wir weiter oder die nächste Iteration.

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

Wenn das Array die Validierungsprüfung bestanden hat, klonen wir unsere Basisstruktur. Das Klonen ist erforderlich, da wir unsere Struktur für viele weitere Iterationen ändern werden.

 // Clone CircuitStructure var structure = (CircuitStructure) baseStructure.Clone(); ConfigureInputs(lines, structure.Conductors, structure.Gates); var sources = AddSourcesLayer(structure.Conductors, maxElementsInLine); var finalElement = AddFinalElement(structure.Conductors); structure.Sources = sources; structure.FinalDevice = finalElement; 

Und schließlich beginnen wir, die Struktur zu modifizieren und sie von unnötigen Elementen zu befreien. Sie wurden durch strukturelle Veränderungen unnötig.

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

Ich sehe den Punkt nicht im Detail, um Dutzende kleiner Funktionen zu analysieren, die "irgendwo dort" in den Tiefen ausgeführt werden.

Variantengenerator


Die Struktur + Elemente, die darin enthalten sein sollten, heißen CircuitVariant.

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

Das erste Feld ist eine Verknüpfung zur Struktur. Die zweiten beiden Wörterbücher, in denen der Schlüssel die Nummer der Ebene ist und der Wert ein Array ist, das die Anzahl der Elemente an ihren Stellen in der Struktur enthält.

Wir fahren mit der Auswahl der Kombinationen fort. Wir können eine bestimmte Anzahl gültiger Logikelemente und Leiter haben. Insgesamt können 6 Logikelemente und 2 Leiter vorhanden sein.
Sie können sich ein Zahlensystem mit einer Basis von 6 vorstellen und in jeder Kategorie die Zahlen erhalten, die den Elementen entsprechen. Durch Erhöhen dieser Hexadezimalzahl können Sie also alle Kombinationen von Elementen durchlaufen.

Das heißt, eine hexadezimale Zahl von drei Ziffern besteht aus 3 Elementen. Es ist nur zu berücksichtigen, dass die Anzahl der Elemente nicht 6, sondern 4 übertragen werden kann.

Um eine solche Zahl zu entladen, habe ich die Struktur bestimmt


 public struct ClampedInt { public int Value { get => value; set => this.value = Mathf.Clamp(value, 0, MaxValue); } public readonly int MaxValue; private int value; public ClampedInt(int maxValue) { MaxValue = maxValue; value = 0; } public bool TryIncrease() { if (Value + 1 <= MaxValue) { Value++; return false; } // overflow return true; } } 


Als nächstes kommt eine Klasse mit dem seltsamen Namen OverflowArray . Das Wesentliche ist, dass es das ClampedInt- Array speichert und die hohe Ordnung erhöht, falls ein Überlauf in der niedrigen Ordnung usw. auftritt , bis der Maximalwert in allen Zellen erreicht ist.

In Übereinstimmung mit jedem ClampedInt werden die Werte des entsprechenden ElectricalElementContainer festgelegt. Somit ist es möglich, alle möglichen Kombinationen auszusortieren. Wenn Sie ein Schema mit Elementen (z. B. And (0) und Xor (4)) erstellen möchten, müssen Sie nicht alle Optionen sortieren, einschließlich der Elemente 1, 2, 3. Dafür erhalten die Elemente während der Generierung ihre lokalen Nummern (z. B. And = 0, Xor = 1) und werden anschließend wieder in globale Nummern konvertiert.

So können Sie alle möglichen Kombinationen in allen Elementen durchlaufen.

Nachdem die Werte in den Containern eingestellt wurden, wird die Schaltung mit Solver auf Lösungen überprüft. Wenn die Schaltung die Entscheidung besteht, kehrt sie zurück.

Nachdem die Schaltung erzeugt wurde, wird die Anzahl der Lösungen überprüft. Es sollte den Grenzwert nicht überschreiten und keine Entscheidungen treffen, die ausschließlich aus 0 oder 1 bestehen.

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


Jeder der Generatoren gibt eine Variante mit der Yield-Anweisung zurück. So generiert CircuitGenerator mit StructureGenerator und VariantsGenerator IEnumerable. (Der Ansatz mit Ertrag hat in Zukunft sehr geholfen, siehe unten)

Daraus folgt, dass der Optionsgenerator eine Liste von Strukturen erhält. Sie können Optionen für jede Struktur unabhängig generieren. Dies könnte parallelisiert werden, aber das Hinzufügen von AsParallel funktionierte nicht (wahrscheinlich ergeben sich Interferenzen). Das manuelle Parallelisieren wird lange dauern, da wir diese Option verwerfen. Tatsächlich habe ich versucht, eine parallele Generierung durchzuführen, es hat funktioniert, aber es gab einige Schwierigkeiten, weil es nicht in das Repository ging.

Spielklassen


Entwicklungsansatz und DI


Inhalt

Das Projekt wird unter Dependency Injection (DI) erstellt. Dies bedeutet, dass Klassen einfach eine Art Objekt benötigen können, das der Schnittstelle entspricht, und nicht an der Erstellung dieses Objekts beteiligt sind. Was sind die Vorteile:

  • Der Ort der Erstellung und Initialisierung des Abhängigkeitsobjekts wird an einer Stelle definiert und von der Logik der abhängigen Klassen getrennt, wodurch die Codeduplizierung beseitigt wird.
  • Beseitigt die Notwendigkeit, den gesamten Abhängigkeitsbaum auszugraben und alle Abhängigkeiten zu instanziieren.
  • Ermöglicht das einfache Ändern der Implementierung der Schnittstelle, die an vielen Stellen verwendet wird.

Als DI-Container im Projekt wird Zenject verwendet.

Zenject hat mehrere Kontexte, ich benutze nur zwei davon:

  • Projektkontext - Registrierung von Abhängigkeiten innerhalb der gesamten Anwendung.
  • Szenenkontext: Die Registrierung von Klassen, die nur in einer bestimmten Szene existieren und deren Lebensdauer durch die Lebensdauer der Szene begrenzt ist.
  • Ein statischer Kontext ist ein allgemeiner Kontext für alles im Allgemeinen. Die Besonderheit ist, dass er im Editor vorhanden ist. Ich benutze für die Injektion im Editor

Die Klassenregistrierung wird in Installer s gespeichert. Ich verwende ScriptableObjectInstaller für den Projektkontext und MonoInstaller für den Szenenkontext.

Die meisten Klassen, die ich bei AsSingle registriere, sind eher Container für Methoden, da sie keinen Status enthalten. Ich verwende AsTransient für Klassen, in denen es einen internen Status gibt, der anderen Klassen nicht gemeinsam sein sollte.

Danach müssen Sie irgendwie MonoBehaviour-Klassen erstellen, die diese Elemente darstellen. Ich habe auch Klassen in Bezug auf Unity einem separaten Projekt zugewiesen, abhängig vom Kernprojekt.



Für MonoBehaviour-Klassen ziehe ich es vor, meine eigenen Schnittstellen zu erstellen. Dies ermöglicht Ihnen zusätzlich zu den Standardvorteilen von Schnittstellen, eine sehr große Anzahl von MonoBehaviour-Mitgliedern auszublenden.

Der Einfachheit halber erstellt DI häufig eine einfache Klasse, in der die gesamte Logik ausgeführt wird, und einen MonoBehaviour-Wrapper dafür. Zum Beispiel hat die Klasse Start- und Aktualisierungsmethoden. Ich erstelle solche Methoden in der Klasse, füge dann in der MonoBehaviour-Klasse ein Abhängigkeitsfeld hinzu und rufe in den entsprechenden Methoden Start und Aktualisierung auf. Dies gibt dem Konstrukteur die „richtige“ Injektion, die Ablösung der Hauptklasse vom DI-Behälter und die Fähigkeit, leicht zu testen.

Konfiguration


Inhalt

Mit Konfiguration meine ich Daten, die der gesamten Anwendung gemeinsam sind. In meinem Fall sind dies Fertighäuser, Kennungen für Werbung und Einkäufe, Tags, Szenennamen usw. Für diese Zwecke verwende ich ScriptableObjects:

  1. Für jede Datengruppe wird eine ScriptableObject-Nachkommenklasse zugewiesen.
  2. Es werden die erforderlichen serialisierbaren Felder erstellt
  3. Leseeigenschaften aus diesen Feldern werden hinzugefügt.
  4. Die Schnittstelle zu den obigen Feldern ist hervorgehoben
  5. Eine Klasse registriert sich bei einer Schnittstelle in einem DI-Container
  6. Gewinn

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

Für die Konfiguration ein separates Installationsprogramm (Code abgekürzt):

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

Elektrische Elemente


Inhalt

Jetzt müssen Sie sich die elektrischen Elemente irgendwie vorstellen

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

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

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

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

Wir haben die Zeile public IElectricalElement Element {get; set; }}

Nur hier ist, wie man diesen Artikel installiert?
Eine gute Option wäre, generisch zu machen:
public class ElectricalElementMb: MonoBehaviour, IElectricalElementMb wobei T: IElectricalElement
Aber der Haken ist, dass Unity generisch in MonoBehavior-Klassen nicht unterstützt. Darüber hinaus unterstützt Unity keine Serialisierung von Eigenschaften und Schnittstellen.

Trotzdem ist es zur Laufzeit durchaus möglich, IElectricalElement Element {get; set; }}
gewünschter Wert.

Ich habe enum ElectricalElementType erstellt, in dem alle erforderlichen Typen vorhanden sind. Enum wird von Unity gut serialisiert und im Inspektor als Dropdown-Liste angezeigt. Definiert zwei Arten von Elementen: Welche werden zur Laufzeit erstellt und welche werden im Editor erstellt und können gespeichert werden. Somit gibt es IElectricalElementMb und IElectricalElementMbEditor, die zusätzlich ein Feld vom Typ ElectricalElementType enthalten.

Der zweite Typ muss ebenfalls zur Laufzeit initialisiert werden. Zu diesem Zweck gibt es eine Klasse, die zu Beginn alle Elemente umgeht und sie abhängig vom Typ im Aufzählungsfeld initialisiert. Wie folgt:

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

Spielverwaltung


Inhalt

Als nächstes stellt sich die Frage, wo die Logik des Spiels selbst platziert werden soll (Überprüfen der Durchgangsbedingungen, Zählen der Durchgänge der Passage und Helfen des Spielers). Es gibt auch Fragen zum Speicherort der Logik zum Speichern und Laden von Fortschritten, Einstellungen und anderen Dingen.

Dazu unterscheide ich bestimmte Managerklassen, die für eine bestimmte Aufgabenklasse verantwortlich sind.

DataManager ist für das Speichern von Daten aus den Ergebnissen der Übergabe der Benutzer- und Spieleinstellungen verantwortlich. Es wird von AsSingle im Rahmen des Projekts registriert. Dies bedeutet, dass er einer für die gesamte Anwendung ist. Während die Anwendung ausgeführt wird, werden Daten direkt im DataManager gespeichert.
Er verwendet den IFileStoreService , der für das Laden und Speichern von Daten und IFileSerializer verantwortlich istverantwortlich für die Serialisierung von Dateien in einer vorgefertigten Form zum Speichern.

LevelGameManager ist ein Spielmanager in einer einzelnen Szene.
Ich habe ein kleines GodObject bekommen, weil er immer noch für die Benutzeroberfläche verantwortlich ist, dh das Öffnen und Schließen des Menüs, die Reaktion auf die Schaltflächen. Angesichts der Größe des Projekts und der fehlenden Notwendigkeit, es zu erweitern, ist dies jedoch akzeptabel. Eine noch einfachere und besser sichtbare Abfolge von Aktionen.

Es gibt zwei Möglichkeiten. Dies ist, was LevelGameManager1 und LevelGameManager2 für Modus 1 bzw. 2 aufgerufen werden .

Im ersten Fall basiert die Logik auf der Reaktion auf den Fall einer Änderung des Werts in einer der Quellen und der Überprüfung des Werts am Ausgang der Schaltung.

Im zweiten Fall reagiert die Logik auf ein Elementänderungsereignis und überprüft auch die Werte am Schaltungsausgang.

Es gibt einige aktuelle Level-Informationen wie Level-Nummer und Spielerunterstützung.

Daten zur aktuellen Ebene werden in CurrentLevelData gespeichert . Dort wird eine Levelnummer gespeichert - eine boolesche Eigenschaft mit einem Check for Help, einem Angebotsflag zur Auswertung des Spiels und Daten zur Unterstützung des Spielers.

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

Hilfe für den ersten Modus sind die Quellennummern und -werte. Im zweiten Modus ist dies der Elementtyp, der in der Zelle festgelegt werden muss.

Die Auflistung enthält Strukturen, in denen die Position und der Wert gespeichert sind, die an der angegebenen Position festgelegt werden müssen. Ein Wörterbuch wäre schöner, aber Unity kann keine Wörterbücher serialisieren.

Die Unterschiede zwischen den Szenen verschiedener Modi bestehen darin, dass im Kontext der Szene ein anderer LevelGameManager und ein anderer ICurrentLevelData festgelegt werden .

Im Allgemeinen habe ich einen ereignisgesteuerten Ansatz für die Kommunikation von Elementen. Einerseits ist es logisch und bequem. Auf der anderen Seite besteht die Möglichkeit, Probleme zu bekommen, ohne sich bei Bedarf abzumelden. Trotzdem gab es bei diesem Projekt keine Probleme und der Umfang ist nicht zu groß. Normalerweise erfolgt zu Beginn der Szene ein Abonnement für alles, was Sie benötigen. Zur Laufzeit wird fast nichts erstellt, daher gibt es keine Verwirrung.

Level Laden


Inhalt

Jedes Level im Spiel wird durch eine Unity-Szene dargestellt. Es muss ein Level-Präfix und eine Nummer enthalten, z. B. „Level23“. Das Präfix ist in der Konfiguration enthalten. Das Laden der Ebene erfolgt über den Namen, der aus dem Präfix gebildet wird. Somit kann die LevelsManager- Klasse Ebenen nach Anzahl laden.

Zwischensequenzen


Inhalt

Zwischensequenzen sind gewöhnliche Unity-Szenen mit Zahlen im Titel, ähnlich den Ebenen.
Die Animation selbst wird mithilfe der Zeitleiste implementiert. Leider habe ich weder Animationsfähigkeiten noch die Fähigkeit, mit Timeline zu arbeiten. Schießen Sie also nicht auf den Pianisten - er spielt so gut er kann.



Die Wahrheit stellte sich heraus, dass eine logische Zwischensequenz aus verschiedenen Szenen mit verschiedenen Objekten bestehen sollte. Es stellte sich heraus, dass dies etwas spät bemerkt wurde, aber es wurde einfach entschieden: indem Teile der Zwischensequenzen an verschiedenen Stellen auf der Bühne platziert und die Kamera sofort bewegt wurden.



Zusätzliches Gameplay


Inhalt

Das Spiel wird anhand der Anzahl der Aktionen pro Level und der Verwendung von Hinweisen geschätzt. Je weniger Action desto besser. Durch die Verwendung des Tooltips wird die maximale Bewertung auf 2 Sterne reduziert und die Stufe auf 1 Stern übersprungen. Um die Passage zu bewerten, wird die Anzahl der Schritte zum Passieren gespeichert. Es besteht aus zwei Werten: dem Minimalwert (für 3 Sterne) und dem Maximum (1 Stern).

Die Anzahl der Schritte zum Übergeben von Ebenen wird nicht in der Szenendatei selbst, sondern in der Konfigurationsdatei gespeichert, da Sie die Anzahl der Sterne für die übergebene Ebene anzeigen müssen. Dies erschwerte den Prozess der Erstellung von Ebenen geringfügig. Es war besonders interessant, die Änderungen im Versionskontrollsystem zu sehen:



Versuchen Sie zu erraten, zu welcher Ebene es gehört. Es war natürlich möglich, das Wörterbuch zu speichern, aber erstens wurde es nicht von Unity serialisiert, zweitens musste man die Nummern manuell einstellen.

Wenn es für den Spieler schwierig ist, das Level zu beenden, kann er einen Hinweis erhalten - die richtigen Werte für einige Eingaben oder das richtige Element im zweiten Modus. Dies wurde auch manuell durchgeführt, obwohl es automatisiert werden konnte.

Wenn die Hilfe des Spielers nicht geholfen hat, kann er das Level komplett überspringen. Wenn ein Level fehlt, erhält der Spieler 1 Stern für ihn.

Ein Benutzer, der eine Ebene mit einem Hinweis übergeben hat, kann sie eine Weile nicht erneut ausführen, sodass es schwierig ist, die Ebene mit neuem Speicher wie ohne Hinweis erneut auszuführen.

Monetarisierung


Inhalt

Das Spiel hat zwei Arten der Monetarisierung: Anzeigen anzeigen und Anzeigen für Geld deaktivieren. Eine Anzeigenanzeige umfasst das Anzeigen von Anzeigen zwischen Ebenen und das Anzeigen von belohnten Anzeigen, um eine Ebene zu überspringen.

Wenn der Spieler bereit ist, für das Deaktivieren von Werbung zu zahlen, kann er dies tun. In diesem Fall werden Anzeigen zwischen Ebenen und beim Überspringen einer Ebene nicht angezeigt.

Für die Werbung wurde eine Klasse namens AdsService mit einer Schnittstelle erstellt

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

Hier ist HelpAd eine belohnte Anzeige für das Überspringen eines Levels. Anfangs haben wir die Hilfe teilweise und vollständig angerufen. Teilweise ist ein Hinweis und voll ist eine Sprungstufe.

Diese Klasse enthält innerhalb der Begrenzung der Häufigkeit, mit der Anzeigen nach dem ersten Start des Spiels nach Zeit geschaltet werden.

Die Implementierung verwendet das Google Mobile Ads Unity Plugin .

Mit belohnter Werbung bin ich auf einen Rechen getreten - es stellt sich heraus, dass loyale Delegierte in einem anderen Thread angerufen werden können, es ist nicht ganz klar, warum. Daher ist es besser, dass diese Delegaten im Code, der sich auf Unity bezieht, nichts aufrufen. Wenn ein Kauf getätigt wurde, um Werbung zu deaktivieren, wird die Werbung nicht angezeigt und der Delegierte führt sofort die erfolgreiche Anzeige der Werbung aus.

Es gibt eine Schnittstelle zum Einkaufen

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

In der Implementierung wird Unity IAP verwendet

. Es gibt einen Trick beim Kauf von Anzeigenabschaltungen. Google Play scheint keine Informationen darüber zu liefern, dass der Spieler einen Kauf gekauft hat. Es wird nur die Bestätigung kommen, dass sie einmal bestanden hat. Wenn Sie jedoch den Status des Produkts nach dem Kauf nicht als abgeschlossen, sondern als ausstehend festlegen , können Sie die Eigenschaft des hasReceipt- Produkts überprüfen . Wenn dies zutrifft, wurde der Kauf abgeschlossen.

Obwohl es natürlich einen solchen Ansatz verwirrt. Ich vermute, dass es nicht alles glatt sein kann.

Die RemoveDisableAd-Methode wird zum Zeitpunkt des Tests benötigt und entfernt den gekauften Werbeausfall.

Benutzeroberfläche


Inhalt

Alle Schnittstellenelemente arbeiten nach einem ereignisorientierten Ansatz. Schnittstellenelemente selbst enthalten normalerweise keine andere Logik als Ereignisse, die von öffentlichen Methoden aufgerufen werden, die Unity verwenden kann. Es werden zwar auch einige Aufgaben ausgeführt, die sich nur auf die Schnittstelle beziehen.

  public abstract class UiElementBase : MonoBehaviour, IUiElement { public event Action ShowClick; public event Action HideCLick; public void Show() { gameObject.SetActive(true); ShowClick?.Invoke(); } public void Hide() { gameObject.SetActive(false); HideCLick?.Invoke(); } } public class PauseMenu : UiElementEscapeClose, IPauseMenu { [SerializeField] private Text levelNumberText; [SerializeField] private LocalizedText finishedText; [SerializeField] private GameObject restartButton; private int levelNumber; public event Action GoToMainMenuClick; public event Action RestartClick; public int LevelNumber { set => levelNumberText.text = $"{finishedText.Value} {value}"; } public void DisableRestartButton() { restartButton.SetActive(false); } public void GoToMainMenu() { GoToMainMenuClick?.Invoke(); } public void Restart() { RestartClick?.Invoke(); } } 

In der Tat ist dies nicht immer der Fall. Es ist gut, diese Elemente als aktive Ansicht zu belassen und daraus einen Ereignis-Listener zu machen, so etwas wie einen Controller, der die erforderlichen Aktionen für Manager auslöst.

Analytik


Inhalt

Auf dem Weg des geringsten Widerstands wurde Unity Analytics ausgewählt . Einfach zu implementieren, obwohl für ein kostenloses Abonnement beschränkt - es ist unmöglich, die Quelldaten zu exportieren. Es gibt auch ein Limit für die Anzahl der Events - 100 / Stunde pro Spieler.
Erstellen Sie für die Analyse die Wrapper-Klasse AnalyticsService . Es verfügt über Methoden für jeden Ereignistyp, empfängt die erforderlichen Parameter und bewirkt, dass das Ereignis mit den in Unity integrierten Tools gesendet wird. Das Erstellen einer Methode für jede Veranstaltung ist sicherlich nicht die beste Vorgehensweise insgesamt, aber in einem wissentlich kleinen Projekt ist es besser, als etwas Großes und Kompliziertes zu tun.
Alle verwendeten Ereignisse sind CustomEvent.. Sie werden aus dem Namen des Ereignisses und dem Namen und Wert des Wörterbuchparameters erstellt. AnalyticsService ruft die erforderlichen Werte aus den Parametern ab und erstellt darin ein Wörterbuch.

Alle Ereignisnamen und Parameter werden in Konstanten platziert. Nicht in Form eines herkömmlichen Ansatzes mit ScriptableObject, da sich diese Werte niemals ändern sollten.

Methodenbeispiel:

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

Kamerapositionierung und Diagramme


Inhalt

Die Aufgabe besteht darin, FinalDevice im gleichen Abstand vom oberen Rand und Quellen vom unteren Rand oben auf dem Bildschirm zu platzieren, auch immer im gleichen Abstand vom unteren Rand. Darüber hinaus haben die Bildschirme unterschiedliche Seitenverhältnisse. Sie müssen die Größe der Kamera anpassen, bevor Sie den Pegel starten, damit er richtig in die Schaltung passt.

Dazu wird die CameraAlign- Klasse erstellt . Größenalgorithmus:

  1. Finde alle notwendigen Elemente auf der Bühne
  2. Finden Sie die minimale Breite und Höhe basierend auf dem Seitenverhältnis
  3. Bestimmen Sie die Kameragröße
  4. Stellen Sie die Kamera in die Mitte
  5. Verschieben Sie FinalDevice an den oberen Bildschirmrand
  6. Verschieben Sie die Quellen an den unteren Bildschirmrand

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

Diese Methode wird aufgerufen, wenn die Szene in der Wrapper-Klasse beginnt.

Farbschemata


Inhalt

Da das Spiel eine sehr primitive Oberfläche haben wird, habe ich mich für zwei Farbschemata entschieden, Schwarz und Weiß.

Dazu wurde eine Schnittstelle erstellt

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

Farben können direkt im Unity-Editor eingestellt werden, dies kann zum Testen verwendet werden. Dann können sie umgeschaltet werden und haben zwei Farbsätze.

Hintergrund- und Vordergrundfarben können sich ändern, ein Farbakzent in jedem Modus.

Da der Player ein nicht standardmäßiges Thema festlegen kann, müssen die Farbdaten in der Einstellungsdatei gespeichert werden. Wenn die Einstellungsdatei keine Farbdaten enthielt, werden diese mit Standardwerten gefüllt.

Dann gibt es mehrere Klassen: CameraColorAdjustment - verantwortlich für das Einstellen der Hintergrundfarbe an der Kamera, UiColorAdjustment - Einstellen der Farben von Schnittstellenelementen und TextMeshColorAdjustment- Legt die Farbe der Zahlen auf den Quellen fest. UiColorAdjustment verwendet auch Tags. Im Editor können Sie jedes Element mit einem Tag markieren, das angibt, für welche Art von Farbe es festgelegt werden soll (Hintergrund, Vordergrund, AccentColor und FixedColor). Dies wird alles zu Beginn der Szene oder bei einem Farbschemawechsel eingestellt.

Ergebnis:





Editor-Erweiterungen


Inhalt

Um den Entwicklungsprozess zu vereinfachen und zu beschleunigen, ist es häufig erforderlich, das richtige Tool zu erstellen, das von Standard-Editor-Tools nicht bereitgestellt wird. Der traditionelle Ansatz in Unity besteht darin, eine EditorWindow-Nachkommenklasse zu erstellen. Es gibt auch einen Ansatz mit UiElements, der sich jedoch noch in der Entwicklung befindet. Daher habe ich mich für den traditionellen Ansatz entschieden.

Wenn Sie einfach eine Klasse erstellen, die etwas aus dem UnityEditor-Namespace neben anderen Klassen für das Spiel verwendet, wird das Projekt einfach nicht zusammengestellt, da dieser Namespace im Build nicht verfügbar ist. Es gibt verschiedene Lösungen:

  • Wählen Sie ein separates Projekt für Editor-Skripte aus
  • Legen Sie Dateien im Ordner Assets / Editor ab
  • Wickeln Sie diese Dateien in #if UNITY_EDITOR

Das Projekt verwendet den ersten Ansatz und manchmal #if UNITY_EDITOR, falls erforderlich, fügt der Klasse, die im Build erforderlich ist, einen kleinen Teil für den Editor hinzu.

Alle Klassen, die nur in dem Editor benötigt werden, den ich in der Assembly definiert habe und der nur im Editor verfügbar ist. Sie wird nicht zum Build des Spiels gehen.



Es wäre jetzt schön, DI in Ihren Editor-Erweiterungen zu haben. Dafür benutze ich Zenject.StaticContext. Um es im Editor festzulegen, wird eine Klasse mit dem Attribut InitializeOnLoad verwendet, in der sich ein statischer Konstruktor befindet.

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

Um ScriptableObject-Klassen in einem statischen Kontext zu registrieren, verwende ich den folgenden Code:

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

Die Implementierung ist nur für diese Zeile erforderlich. AssetDatabase.LoadAssetAtPath (Pfad)

Es ist nicht möglich, dem Konstruktor eine Abhängigkeit hinzuzufügen .
Fügen Sie stattdessen das Attribut [Inject] zu den Abhängigkeitsfeldern in der Fensterklasse hinzu und rufen Sie beim Start des Fensters StaticContext.Container.Inject (this) auf .

Ich empfehle außerdem, dem Fensteraktualisierungszyklus eine Nullprüfung eines der abhängigen Felder hinzuzufügen. Wenn das Feld leer ist, führen Sie die obige Zeile aus. Denn nach dem Ändern des Codes im Projekt kann Unity das Fenster neu erstellen und Awake nicht darauf aufrufen.

Generator


Inhalt


Erstansicht des Generators

Das Fenster sollte eine Schnittstelle zum Generieren einer Liste von Schemata mit Parametern, Anzeigen einer Liste von Schemata und Platzieren des ausgewählten Schemas in der aktuellen Szene bieten.

Das Fenster besteht aus drei Abschnitten von links nach rechts:

  • Generierungseinstellungen
  • Liste der Optionen in Form von Schaltflächen
  • ausgewählte Option als Text

Spalten werden mit EditorGUILayout.BeginVertical () und EditorGUILayout.EndVertical () erstellt. Leider hat es nicht funktioniert, die Größen zu korrigieren und zu begrenzen, aber das ist nicht so kritisch.

Es stellte sich heraus, dass der Erzeugungsprozess auf einer großen Anzahl von Schaltkreisen nicht so schnell ist. Mit den Elementen von I werden viele Kombinationen erhalten. Wie der Profiler gezeigt hat, ist der langsamste Teil die Schaltung selbst. Parallelisierung ist keine Option, alle Optionen verwenden ein Schema, aber es ist schwierig, diese Struktur zu klonen.

Dann dachte ich, dass wahrscheinlich der gesamte Code der Editor-Erweiterungen im Debug-Modus funktioniert. Unter Release funktioniert das Debuggen nicht so gut, Haltepunkte werden nicht gestoppt, Zeilen werden übersprungen usw. Nachdem die Leistung gemessen wurde, stellte sich heraus, dass die Geschwindigkeit des Generators in Unity der von der Konsolenanwendung gestarteten Debug-Assembly entspricht, die ~ 6-mal langsamer als Release ist. Denken Sie daran.

Alternativ können Sie eine externe Assemblierung durchführen und mit der Assembly zur Unity-DLL hinzufügen. Dies erschwert jedoch die Assemblierung und Bearbeitung des Projekts erheblich.

Der Generierungsprozess wurde sofort in eine separate Task mit dem folgenden Code gebracht:
CircuitGenerator.Generate (Zeilen, maxElementsInLine, availableLogicalElements, useNOT, Änderung) .ToList ()

Bereits besser, der Editor hängt zum Zeitpunkt der Generierung nicht. Es ist jedoch weiterhin erforderlich, einige Minuten lang zu warten (mehr als 20 Minuten bei großen Schaltkreisen). Außerdem gab es das Problem, dass die Aufgabe nicht so einfach erledigt werden kann und so lange funktioniert, bis die Generierung abgeschlossen ist.

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


Die Idee ist, dass der Hintergrund generiert wird und auf Anfrage die interne Liste der sortierten Optionen aktualisiert wird. Dann können Sie Seite für Seite Optionen auswählen. Somit muss nicht jedes Mal sortiert werden, was die Arbeit an großen Listen erheblich beschleunigt. Die Schemata sind nach „Interessantheit“ sortiert: nach Anzahl der Lösungen, nach Erhöhung und nach den verschiedenen Werten, die für die Lösung erforderlich sind. Das heißt, eine Schaltung mit einer Lösung von 1 1 1 1 ist weniger interessant als 1 0 1 1.



Somit stellte sich heraus, ohne auf das Ende der Erzeugung zu warten, bereits eine Schaltung für den Pegel auszuwählen. Ein weiteres Plus ist, dass der Editor aufgrund der Paginierung nicht wie Vieh langsamer wird.

Die Unity-Funktion ist sehr störend, da beim Klicken auf Wiedergabe der Inhalt des Fensters wie alle generierten Daten zurückgesetzt wird. Wenn sie leicht serialisierbar wären, könnten sie als Dateien gespeichert werden. Auf diese Weise können Sie sogar die Ergebnisse der Generierung zwischenspeichern. Leider ist es schwierig, eine komplexe Struktur zu serialisieren, in der Objekte aufeinander verweisen.

Außerdem habe ich jedem Tor Zeilen hinzugefügt, wie z

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

Welches stark verbesserte Leistung.

Löser


Inhalt

Wenn Sie die Schaltung im Editor zusammenstellen, müssen Sie schnell verstehen können, ob sie gelöst wird und wie viele Lösungen sie hat. Zu diesem Zweck habe ich ein "Solver" -Fenster erstellt. Es bietet Lösungen für das aktuelle Schema in Form eines Textes.



Die Logik seines „Backends“:

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

Nützlich


Inhalt

Asserthelp


Inhalt
Um zu überprüfen, ob die Werte in Assets festgelegt sind, verwende ich Erweiterungsmethoden, die ich in OnEnable aufrufe

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

Das Überprüfen, ob die Szene geladen werden kann, schlägt manchmal fehl, obwohl die Szene möglicherweise geladen wird. Vielleicht ist dies ein Fehler in Unity.

Anwendungsbeispiele:

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

SceneObjectsHelper


Inhalt

Für die Arbeit mit Szenenelementen war auch die SceneObjectsHelper-Klasse hilfreich:

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


Hier sind einige Dinge möglicherweise nicht sehr effektiv, wenn eine hohe Leistung erforderlich ist, aber sie werden selten für mich benötigt und erzeugen keinen Einfluss. Mit ihnen können Sie beispielsweise Objekte über die Benutzeroberfläche finden, die ziemlich hübsch aussieht.

Coroutinestarter


Inhalt

Coroutine starten kann nur MonoBehaviour. Also habe ich die CoroutineStarter-Klasse erstellt und im Kontext der Szene registriert.

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

Die Einführung solcher Tools erleichterte nicht nur die Bequemlichkeit, sondern auch das automatische Testen. Zum Beispiel die Ausführung von Coroutine in Tests:

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

Gizmo


Inhalt

Um unsichtbare Elemente bequem anzeigen zu können, empfehle ich die Verwendung von Gizmo-Bildern, die nur in der Szene sichtbar sind. Sie erleichtern die Auswahl eines unsichtbaren Elements mit einem Klick. Auch Verbindungen von Elementen in Form von Linien hergestellt:

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



Testen


Inhalt

Ich wollte das Beste aus automatischen Tests herausholen, da Tests verwendet wurden, wo immer dies möglich und einfach zu verwenden war.

Für Unit-Tests ist es üblich, Scheinobjekte anstelle der Klassen zu verwenden, die die Schnittstelle implementieren, von der die Testklasse abhängt. Dafür habe ich die NSubstitute- Bibliothek benutzt . Was freut mich sehr.

Unity unterstützt NuGet nicht, daher musste ich die DLL separat und dann die Assembly abrufen, da eine Abhängigkeit zur AssemblyDefinition-Datei hinzugefügt und ohne Probleme verwendet wird.



Für automatische Tests bietet Unity TestRunner an, das mit dem sehr beliebten NUnit- Testframework funktioniert . Aus Sicht von TestRunner gibt es zwei Arten von Tests:

  • EditMode — , . Nunit . , . GameObject Monobehaviour . , EditMode .
  • PlayMode — .
EditMode Nach meiner Erfahrung gab es in diesem Modus viele Unannehmlichkeiten und seltsames Verhalten. Trotzdem ist es praktisch, den Zustand der gesamten Anwendung automatisch zu überprüfen. Sie bieten auch eine ehrliche Überprüfung des Codes in Methoden wie Start, Update und dergleichen.

PlayMode-Tests können als normale NUnit-Tests beschrieben werden, es gibt jedoch eine Alternative. Im PlayMode müssen Sie möglicherweise eine Weile oder eine bestimmte Anzahl von Frames warten. Dazu müssen Tests ähnlich wie bei Coroutine beschrieben werden. Der zurückgegebene Wert sollte IEnumerator / IEnumerable sein. Um die Zeit zu überspringen, müssen Sie beispielsweise Folgendes verwenden:

 yield return null; 

oder

 yield return new WaitForSeconds(1); 

Es gibt andere Rückgabewerte.

Bei einem solchen Test muss das UnityTest- Attribut festgelegt werden . Es gibt auch
UnitySetUp- und UnityTearDown-Attribute, mit denen Sie einen ähnlichen Ansatz verwenden müssen.

Ich teile wiederum EditMode-Tests für Modular und Integration.

Unit-Tests testen nur eine Klasse vollständig isoliert von anderen Klassen. Solche Tests erleichtern häufig die Vorbereitung der Umgebung für die zu testende Klasse, und wenn Fehler bestanden werden, können Sie das Problem genauer lokalisieren.

In Unit-Tests teste ich viele Kernklassen und Klassen, die direkt im Spiel benötigt werden.
Die Schaltungselementtests sind sehr ähnlich, daher habe ich eine Basisklasse erstellt

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

Weitere Elementtests sehen folgendermaßen aus:

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

Vielleicht ist dies eine Komplikation in Bezug auf das Verständnis, die in Tests normalerweise nicht erforderlich ist, aber ich wollte nicht elf Mal dasselbe kopieren und einfügen.

Es gibt auch Tests von GameManagern. Da sie viel gemeinsam haben, haben sie auch eine Basisklasse von Tests. Spielmanager in beiden Modi sollten identische und unterschiedliche Funktionen haben. Allgemeine Dinge werden mit den gleichen Tests für jeden Nachfolger getestet und das spezifische Verhalten wird zusätzlich getestet. Trotz des Ereignisansatzes war es nicht schwierig, das Verhalten des Ereignisses zu testen:

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

In Integrationstests habe ich auch Klassen für den Editor getestet und sie aus dem statischen Kontext des DI-Containers übernommen. Überprüfen Sie daher auch die korrekte Injektion, was nicht weniger wichtig ist als der Unit-Test.

 public class PlacerTests { [Inject] private ICircuitEditorPlacer circuitEditorPlacer; [Inject] private ICircuitGenerator circuitGenerator; [Inject] private IEditorSolver solver; [Inject] private ISceneObjectsHelper sceneObjectsHelper; [TearDown] public void TearDown() { sceneObjectsHelper.DestroyObjectsOfType<IElectricalElementMb>(immediate: true); } [OneTimeSetUp] public void Setup() { var container = StaticContext.Container; container.Inject(this); } [TestCase(1, 2)] [TestCase(2, 2)] [TestCase(3, 4)] public void PlaceSolve_And_NoModifications_AllVariantsSolved(int lines, int elementsInLine) { var variants = circuitGenerator.Generate(lines, elementsInLine, new List<int> {0}, false); foreach (var variant in variants) { circuitEditorPlacer.PlaceCircuit(variant); var solutions = solver.FindSolutions(); CollectionAssert.IsNOTEmpty(solutions); } } [TestCase(1, 2, StructureModification.Branching)] [TestCase(1, 2, StructureModification.ThroughLayer)] [TestCase(1, 2, StructureModification.All)] [TestCase(2, 2, StructureModification.Branching)] [TestCase(2, 2, StructureModification.ThroughLayer)] [TestCase(2, 2, StructureModification.All)] public void PlaceSolve_And_Modifications_AllVariantsSolved(int lines, int elementsInLine, StructureModification modification) { var variants = circuitGenerator.Generate(lines, elementsInLine, new List<int> {0}, false, modification); foreach (var variant in variants) { circuitEditorPlacer.PlaceCircuit(variant); var solutions = solver.FindSolutions(); CollectionAssert.IsNOTEmpty(solutions); } } 

Dieser Test verwendet reale Implementierungen aller Abhängigkeiten und setzt auch Objekte auf der Bühne, was in EditMode-Tests durchaus möglich ist. Es ist wahr zu testen, ob es sie gesund gemacht hat - ich habe keine Ahnung, wie, also überprüfe ich, ob die ausgeschriebene Schaltung Lösungen hat.

In der Integration gibt es auch Tests für CircuitGenerator (StructureGenerator + VariantsGenerator) und Solver

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


PlayMode-Tests werden als Systemtests verwendet. Sie überprüfen Fertighäuser, Injektionen usw. Eine gute Option ist die Verwendung vorgefertigter Szenen, in denen der Test nur einige Interaktionen lädt und erzeugt. Aber ich benutze eine vorbereitete leere Szene zum Testen, in der sich die Umgebung von der im Spiel unterscheidet. Es wurde versucht, mit PlayMode den gesamten Spielprozess zu testen, z. B. das Menü aufzurufen, das Level einzugeben usw. Die Arbeit dieser Tests erwies sich jedoch als instabil, sodass beschlossen wurde, sie für später (nie) zu verschieben.

Es ist praktisch, Tools zur Bewertung der Abdeckung zum Schreiben von Tests zu verwenden, aber leider habe ich keine Lösungen gefunden, die mit Unity funktionieren.

Ich fand ein Problem, dass mit dem Unity-Upgrade auf 2018.3 die Tests viel langsamer und bis zu zehnmal langsamer arbeiteten (in einem synthetischen Beispiel). Das Projekt enthält 288 EditMode-Tests, die 11 Sekunden lang ausgeführt werden, obwohl dort so lange nichts unternommen wurde.

Entwicklungszusammenfassung


Inhalt


Screenshot der Spielebene Die

Logik einiger Spiele kann unabhängig von der Plattform formuliert werden. Dies erleichtert in einem frühen Stadium die Entwicklung und Testbarkeit durch Autotests.

DI ist bequem. Selbst unter Berücksichtigung der Tatsache, dass Unity es nicht nativ hat, funktioniert die seitlich verschraubte Seite ziemlich erträglich.

Mit Unity können Sie ein Projekt automatisch testen. Richtig, da alle integrierten GameObject-Komponenten keine Schnittstellen haben und nur direkt zum Verspotten von Dingen wie Collider, SpriteRenderer, MeshRenderer usw. verwendet werden können. wird nicht funktionieren. Mit GetComponent können Sie zwar Komponenten auf der Schnittstelle abrufen. Schreiben Sie optional Ihre eigenen Wrapper für alles.

Die Verwendung von Autotests vereinfachte das Generieren der anfänglichen Logik, während es keine Benutzeroberfläche für den Code gab. Bei mehreren Tests wurde sofort während der Entwicklung ein Fehler festgestellt. Natürlich traten weitere Fehler auf, aber häufig war es möglich, zusätzliche Tests zu schreiben / vorhandene zu ändern und ihn später automatisch abzufangen. Fehler mit DI, Prefabs, skriptfähigen Objekten und dergleichen, Tests sind schwer zu erkennen, aber es ist möglich, da Sie echte Installationsprogramme für Zenject verwenden können, wodurch die Abhängigkeiten enger werden, wie es im Build geschieht.

Unity erzeugt eine große Menge an Fehlern und Abstürzen. Oft werden Fehler durch einen Neustart des Editors behoben. Konfrontiert mit einem seltsamen Verlust von Verweisen auf Objekte in Fertighäusern. Manchmal wird das Fertighaus als Referenz zerstört (ToString () gibt "null" zurück), obwohl alles funktioniert, wird das Fertighaus auf die Szene gezogen und der Link ist nicht leer. Manchmal gehen in allen Szenen einige Verbindungen verloren. Alles scheint installiert zu sein, es hat funktioniert, aber wenn Sie zu einem anderen Zweig wechseln, sind alle Szenen unterbrochen - es gibt keine Verknüpfungen zwischen den Elementen.

Glücklicherweise wurden diese Fehler häufig durch Neustart des Editors oder manchmal durch Löschen des Bibliotheksordners behoben.

Insgesamt ist von der Idee bis zur Veröffentlichung bei Google Play etwa ein halbes Jahr vergangen. Die Entwicklung selbst dauerte ca. 3 Monate in der Freizeit von der Hauptarbeit.

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


All Articles