Architekturlösungen für ein Handyspiel. Teil 1: Modell

Epigraph:
- Wie werde ich bewerten, wenn Sie nicht wissen, was Sie tun sollen?
- Nun, es wird Bildschirme und Schaltflächen geben.
- Dima, du hast jetzt mein ganzes Leben in drei Worten beschrieben!
(c) Echter Dialog bei einer Kundgebung in einem Glücksspielunternehmen



Die Anforderungen und Lösungen, die ich in diesem Artikel erörtern werde, wurden während meiner Teilnahme an etwa einem Dutzend großer Projekte erstellt, zuerst bei Flash und später bei Unity. Das größte der Projekte hatte mehr als 200.000 DAU und ergänzte mein Sparschwein mit neuen ursprünglichen Herausforderungen. Zum anderen wurde die Relevanz und Notwendigkeit früherer Befunde bestätigt.

In unserer harten Realität hat jeder, der mindestens einmal ein großes Projekt entworfen hat, zumindest in seinen Gedanken, seine eigenen Ideen, wie es geht, und ist oft bereit, seine Ideen bis zum letzten Tropfen Blut zu verteidigen. Für andere bringt es mich zum Lächeln, und das Management betrachtet dies alles oft als eine riesige Black Box, die sich gegen niemanden gestellt hat. Aber was ist, wenn ich Ihnen sage, dass die richtigen Lösungen dazu beitragen, die Erstellung neuer Funktionen um das 2-3-fache und die Suche nach Fehlern in den alten 5-10-fachen zu reduzieren und Ihnen viele neue und wichtige Dinge zu ermöglichen, auf die zuvor nicht zugegriffen werden konnte? Es reicht aus, Architektur in Ihr Herz zu lassen!
Architekturlösungen für ein Handyspiel. Teil 2: Befehl und ihre Warteschlangen
Architekturlösungen für ein Handyspiel. Teil 3: Blick auf den Strahlschub


Modell


Zugriff auf Felder


Die meisten Programmierer erkennen, wie wichtig es ist, MVC zu verwenden. Nur wenige Leute verwenden reines MVC aus dem Buch einer vierköpfigen Bande, aber alle Entscheidungen normaler Ämter ähneln diesem Muster im Geiste. Heute werden wir über den ersten Buchstaben in dieser Abkürzung sprechen. Weil ein großer Teil der Arbeit von Programmierern in einem Handyspiel neue Funktionen im Metaspiel sind, die als Manipulationen mit dem Modell implementiert werden und Tausende von Schnittstellen in diese Funktionen einbinden. Und die Bequemlichkeit des Modells spielt in dieser Lektion eine Schlüsselrolle.

Ich gebe nicht den vollständigen Code an, da es sich um eine kleine Dofig handelt und es im Allgemeinen nicht um ihn geht. Ich werde meine Argumentation anhand eines einfachen Beispiels veranschaulichen:

public class PlayerModel { public int money; public InventoryModel inventory; /* Using */ public void SomeTestChanges() { money = 10; inventory.capacity++; } } 

Diese Option passt überhaupt nicht zu uns, da das Modell keine Ereignisse über Änderungen sendet, die darin auftreten. Wenn Informationen darüber, welche Felder von den Änderungen betroffen waren und welche nicht und welche neu gezeichnet werden müssen und welche nicht, der Programmierer in der einen oder anderen Form manuell angibt, wird dies zur Hauptquelle für Fehler und Zeit. Und müssen Sie einfach keine überraschten Augen machen. In den meisten großen Büros, in denen ich gearbeitet habe, hat der Programmierer alle Arten von InventoryUpdatedEvent selbst gesendet und in einigen Fällen auch manuell ausgefüllt. Einige dieser Büros haben Millionen verdient, denken Sie, danke oder trotz?

Wir werden unsere eigene Klasse ReactiveProperty <T> verwenden, die alle Manipulationen zum Senden von Nachrichten, die wir benötigen, unter der Haube verbirgt. Es wird ungefähr so ​​aussehen:

 public class PlayerModel : Model { public ReactiveProperty<int> money = new ReactiveProperty<int>(); public ReactiveProperty<InventoryModel> inventory = new ReactiveProperty<InventoryModel>(); /* Using */ public void SomeTestChanges() { money.Value = 10; inventory.Value.capacity.Value++; } public void Subscription(Text text) { money.SubscribeWithState(text, (x, t) => t.text = x.ToString()); } } 

Dies ist die erste Version des Modells. Diese Option ist für viele Programmierer bereits ein Traum, aber ich mag sie immer noch nicht. Das erste, was mir nicht gefällt, ist, dass der Zugriff auf Werte kompliziert ist. Ich habe es geschafft, beim Schreiben dieses Beispiels verwirrt zu werden und Value an einer Stelle zu vergessen, und genau diese Datenmanipulationen machen den Löwenanteil von allem aus, was mit dem Modell getan und verwechselt wird. Wenn Sie die Sprachversion 4.x verwenden, können Sie dies tun:

 public ReactiveProperty<int> money { get; private set; } = new ReactiveProperty<int>(); 

Dies löst jedoch nicht alle Probleme. Ich möchte einfach schreiben: inventar.capacity ++; Angenommen, wir versuchen, für jedes Modellfeld zu ermitteln. set; Um Ereignisse abonnieren zu können, benötigen wir jedoch auch Zugriff auf ReactiveProperty. Klare Unannehmlichkeiten und Verwirrung. Trotz der Tatsache, dass wir nur angeben müssen, welches Feld wir überwachen werden. Und hier habe ich mir ein kniffliges Manöver ausgedacht, das mir gefallen hat.

Mal sehen, ob es dir gefällt.

Es ist nicht ReactiveProperty, das in das konkrete Modell eingefügt wird, mit dem sich der Programmierer befasst, sondern es wird eingefügt, aber sein statischer Deskriptor PValue, der Erbe der allgemeineren Eigenschaft, identifiziert das Feld und im Inneren unter der Haube des Modellkonstruktors ist die Erstellung und Speicherung der ReactiveProperty des gewünschten Typs verborgen. Nicht der beste Name, aber es ist passiert, dann umbenannt.

Im Code sieht es so aus:

 public class PlayerModel : Model { public static PValue<int> MONEY = new PValue<int>(); public int money { get { return MONEY.Get(this); } set { MONEY.Set(this, value) } } public static PModel<InventoryModel> INVENTORY = new PModel<InventoryModel>(); public InventoryModel inventory { get { return INVENTORY.Get(this); } set { INVENTORY.Set(this, value) } } /* Using */ public void SomeTestChanges() { money = 10; inventory.capacity++; } public void Subscription(Text text) { this.Get(MONEY).SubscribeWithState(text, (x, t) => t.text = x.ToString()); } } 

Dies ist die zweite Option. Der allgemeine Vorfahr des Modells war natürlich auf Kosten der Erstellung und Extraktion einer echten ReactiveProperty gemäß ihrem Deskriptor kompliziert, aber dies kann sehr schnell und ohne Reflexion erfolgen, oder vielmehr, Reflexion nur einmal in der Phase der Klasseninitialisierung anzuwenden. Und dies ist die Arbeit, die der Schöpfer der Engine einmal erledigt hat und die dann von allen verwendet wird. Darüber hinaus vermeidet dieses Design versehentliche Versuche, ReactiveProperty selbst anstelle der darin gespeicherten Werte zu manipulieren. Die Erstellung des Feldes ist unübersichtlich, aber in allen Fällen genau gleich und kann mit einer Vorlage erstellt werden.

Am Ende des Artikels finden Sie eine Umfrage, welche Option Ihnen am besten gefällt.
Alles, was unten beschrieben wird, kann in beiden Versionen implementiert werden.

Transaktionen


Ich möchte, dass Programmierer Modellfelder nur ändern können, wenn dies durch die in der Engine festgelegten Einschränkungen, dh innerhalb des Teams, und nie wieder zulässig ist. Dazu muss der Setter irgendwohin gehen und prüfen, ob der Transaktionsbefehl derzeit geöffnet ist, und erst dann zulassen, dass die Informationen im Modell bearbeitet werden. Dies ist sehr wichtig, da Benutzer der Engine regelmäßig versuchen, etwas Seltsames zu tun, um einen typischen Prozess zu umgehen, die Logik der Engine zu brechen und subtile Fehler zu verursachen. Ich habe das mehr als ein- oder zweimal gesehen.

Es besteht die Überzeugung, dass es irgendwie hilfreich ist, wenn Sie eine separate Schnittstelle zum Lesen von Daten aus dem Modell und zum Schreiben erstellen. In der Realität ist das Modell mit zusätzlichen Dateien und langwierigen zusätzlichen Operationen überwachsen. Diese Einschränkungen sind endgültig. Programmierer sind zum einen gezwungen, sie zu kennen und ständig darüber nachzudenken: „Was sollte jede bestimmte Funktion, jedes Modell oder ihre Schnittstelle bieten?“, Und zum anderen treten Situationen auf, in denen diese Einschränkungen umgangen werden müssen Am Ausgang haben wir d'Artagnan, der sich das alles in Weiß ausgedacht hat, und viele Benutzer seiner Engine, die schlechte Wachen des Projektmanagers sind und trotz ständigen Missbrauchs nichts wie beabsichtigt funktioniert. Daher ziehe ich es vor, die Möglichkeit eines solchen Fehlers nur knapp zu blockieren. Reduzieren Sie sozusagen die Konventionsdosis.

Der ReactiveProperty-Setter sollte einen Link zu dem Ort haben, an dem der aktuelle Status der Transaktion überprüft werden soll. Angenommen, dieser Ort ist classCModelRoot. Am einfachsten ist es, es explizit an den Modellkonstruktor zu übergeben. Die zweite Version des Codes beim Aufrufen von RProperty erhält explizit einen Link dazu und kann von dort aus alle erforderlichen Informationen abrufen. Für die erste Version des Codes müssen Sie die Felder des Typs ReactiveProperty im Konstruktor mit einer Reflexion durchlaufen und ihnen für weitere Manipulationen einen Link dazu geben. Eine leichte Unannehmlichkeit ist die Notwendigkeit, in jedem Modell einen expliziten Konstruktor mit einem Parameter zu erstellen.

 public class PlayerModel : Model { public PlayerModel(ModelRoot gamestate) : base (gamestate) {} } 

Für andere Merkmale von Modellen ist es jedoch sehr nützlich, dass das Modell eine Verknüpfung zum übergeordneten Modell hat und ein zweifach verbundenes Konstrukt bildet. In unserem Beispiel ist dies player.inventory.Parent == player. Und dann kann dieser Konstruktor vermieden werden. Jedes Modell kann von seinem Elternteil und von seinem Elternteil einen Link zu einem magischen Ort abrufen und zwischenspeichern, bis sich herausstellt, dass der nächste Elternteil dieser magische Ort ist. Auf der Ebene der Erklärungen sieht dies alles folgendermaßen aus:

 public class ModelRoot : Model { public bool locked { get; private set; } } public partial class Model { public Model Parent { get; protected set; } public ModelRoot Root { get; } } 

All diese Schönheit wird automatisch gefüllt, wenn das Modell den Gamestate-Baum betritt. Ja, das neu erstellte Modell, das noch nicht dort angekommen ist, kann die Transaktion nicht kennenlernen und Manipulationen mit sich selbst blockieren. Wenn der Transaktionsstatus jedoch verboten ist, kann es danach nicht in den Status versetzt werden. Der Setter des zukünftigen übergeordneten Elements lässt dies nicht zu. Die Integrität des Spielzustands wird also nicht beeinträchtigt. Ja, dies erfordert zusätzliche Arbeit in der Phase der Programmierung der Engine, aber andererseits wird ein Programmierer, der die Engine verwendet, die Notwendigkeit, es zu wissen und darüber nachzudenken, vollständig beseitigen, bis er versucht, etwas falsch zu machen, und von den Händen darin gefangen wird.

Da das Gespräch über die Transaktivität begonnen hat, sollten Nachrichten über Änderungen nicht unmittelbar nach der Änderung verarbeitet werden, sondern nur, wenn alle Manipulationen mit dem Modell innerhalb des aktuellen Befehls abgeschlossen sind. Dafür gibt es zwei Gründe: Der erste ist die Datenkonsistenz. Nicht alle Datenzustände sind intern konsistent. Möglicherweise können Sie nicht versuchen, sie zu rendern. Oder wenn Sie beispielsweise ungeduldig sind, ein Array zu sortieren oder eine Modellvariable in einer Schleife zu ändern. Sie sollten nicht Hunderte von Änderungsnachrichten erhalten.

Es gibt zwei Möglichkeiten, dies zu tun. Die erste besteht darin, die knifflige Funktion zu verwenden, wenn Aktualisierungen einer Variablen abonniert werden, die dem Strom von Änderungen in der Variablen einen Strom von Transaktionsendungen hinzufügen und nur Nachrichten nach ihnen weiterleiten. Dies ist einfach genug, wenn Sie beispielsweise UniRX verwenden. Diese Option weist jedoch viele Mängel auf, insbesondere führt sie zu vielen unnötigen Bewegungen. Persönlich mag ich die andere Option.

Jede ReactiveProperty merkt sich ihren Status vor dem Start der Transaktion und ihren aktuellen Status. Eine Nachricht über die Änderung und Korrektur der Änderungen wird erst am Ende der Transaktion gesendet. In dem Fall, in dem das Objekt der Änderung eine Art Sammlung war, können auf diese Weise explizit Informationen zu den Änderungen in die gesendete Nachricht aufgenommen werden. Beispielsweise wurden zwei solche Elemente in die Liste aufgenommen und diese beiden gelöscht. Anstatt nur zu sagen, dass sich etwas geändert hat, und den Empfänger zu zwingen, eine Liste mit tausend Elementen zu analysieren, um nach Informationen zu suchen, die neu gezeichnet werden müssen.

 public partial class Model { public void DispatchChanges(Command transaction); public void FixChanges(); public void RevertChanges(); } 

Die Option ist in der Phase der Erstellung des Motors zeitaufwändiger, aber dann sind die Nutzungskosten niedriger. Und vor allem eröffnet es die Möglichkeit für die nächste Verbesserung.

Informationen zu Änderungen am Modell


Ich möchte mehr vom Modell. Ich möchte jederzeit einfach und bequem sehen, was sich durch meine Handlungen im Zustand des Modells geändert hat. Zum Beispiel in dieser Form:

 {"player":{"money":10, "inventory":{"capacity":11}}} 

In den meisten Fällen ist es für den Programmierer nützlich, den Unterschied zwischen dem Status des Modells vor dem Start des Befehls und nach dessen Ende oder an einem bestimmten Punkt innerhalb des Befehls zu erkennen. Einige klonen dafür den gesamten Gamestate vor dem Start des Teams und vergleichen dann. Dies löst das Problem teilweise in der Debugging-Phase, aber es ist absolut unmöglich, dies im Produkt auszuführen. Dieses Klonen des Zustands, das Berechnen des unbedeutenden Unterschieds zwischen den beiden Listen, ist eine ungeheuer teure Operation, die mit jedem Niesen zu tun hat.

Daher muss ReactiveProperty nicht nur den aktuellen, sondern auch den vorherigen Status speichern. Daraus ergibt sich eine ganze Gruppe äußerst nützlicher Möglichkeiten. Erstens ist die Extraktion des Unterschieds in einer solchen Situation schnell und wir können alles ruhig in das Essen werfen. Zweitens können Sie aus Änderungen kein sperriges Diff, sondern einen kompakten kleinen Hash erhalten und ihn mit einem Hash von Änderungen in einem anderen gleichen Gamestate vergleichen. Wenn es nicht übereinstimmt, haben Sie Probleme. Drittens können Sie die Änderungen jederzeit abbrechen und sich über den unberührten Zustand zum Zeitpunkt des Starts der Transaktion informieren, wenn die Ausführung des Befehls mit der Ausführung fehlgeschlagen ist. Zusammen mit dem auf den Staat angewandten Team sind diese Informationen von unschätzbarem Wert, da Sie die Situation leicht und genau wiedergeben können. Dazu benötigen Sie natürlich vorgefertigte Funktionen für die bequeme Serialisierung und Deserialisierung des Spielstatus, aber Sie benötigen diese trotzdem.

Serialisierung von Modelländerungen


Die Engine bietet Serialisierung und Binär und in json - und das ist kein Zufall. Natürlich nimmt die binäre Serialisierung viel weniger Speicherplatz ein und arbeitet viel schneller, was besonders beim ersten Start wichtig ist. Dies ist jedoch kein für Menschen lesbares Format, und hier beten wir für die Bequemlichkeit des Debuggens. Darüber hinaus gibt es eine weitere Gefahr. Wenn Ihr Spiel auf den Markt kommt, müssen Sie ständig von Version zu Version wechseln. Wenn Ihre Programmierer einige einfache Vorsichtsmaßnahmen befolgen und nichts unnötig aus dem Spielstatus löschen, werden Sie diesen Übergang nicht spüren. Und im Binärformat gibt es aus offensichtlichen Gründen keine Feldzeichenfolgennamen. Wenn die Versionen nicht übereinstimmen, müssen Sie die Binärdatei mit der alten Version des Status lesen, sie in einen informativeren Zustand exportieren, z. B. denselben JSON, und sie dann in einen neuen Status importieren und in die Binärdatei exportieren. aufschreiben, und erst nach all dem weiterarbeiten wie gewohnt. In einigen Projekten werden Konfigurationen daher aufgrund ihrer zyklopischen Größe in Binärdateien geschrieben, und sie ziehen es bereits vor, den Status in Form von json hin und her zu ziehen. Bewerten Sie den Overhead und wählen Sie Sie aus.

 [Flags] public enum ExportMode { all = 0x0, changes = 0x1, serverVerified = 0x2, //    ,    } /**    */ public partial class Model { public bool GetHashCode(ExportMode mode, out int code); public bool Import(BinaryReader binarySerialization); public bool Import(JSONReader json); public void ExportAll(ExportMode mode, BinaryWriter binarySerialization); public void ExportAll(ExportMode mode, JSONWriter json); public bool Export(ExportMode mode, out Dictionary<string, object> data); } 

Die Signatur der Exportmethode (ExportMode-Modus, out Dictionary <string, object> data) ist etwas alarmierend. Und die Sache ist folgende: Wenn Sie den gesamten Baum serialisieren, können Sie sofort in den Stream oder in unserem Fall in JSONWriter schreiben, ein einfaches Add-On zu StringWriter. Wenn Sie Änderungen exportieren, ist dies jedoch nicht so einfach. Wenn Sie tief in einen Baum und in einen der Zweige gehen, wissen Sie immer noch nicht, ob Sie überhaupt etwas daraus exportieren sollen. Daher habe ich zu diesem Zeitpunkt zwei Lösungen gefunden, eine einfachere, eine kompliziertere und wirtschaftlichere. Einfacher ist, dass Sie beim Exportieren nur von Änderungen alle Änderungen in einen Baum aus Dictionary <Zeichenfolge, Objekt> und List <Objekt> umwandeln. Und was dann passiert ist, füttere deinen Lieblingsserialisierer. Dies ist ein einfacher Ansatz, bei dem nicht mit einem Tamburin getanzt werden muss. Der Nachteil ist jedoch, dass beim Exportieren von Änderungen in den Heap ein Platz für einmalige Sammlungen zugewiesen wird. Tatsächlich gibt es nicht viel Speicherplatz, da dieser vollständige Export einen großen Baum ergibt und der typische Befehl nur sehr wenige Änderungen im Baum hinterlässt.

Viele Menschen glauben jedoch, dass es nicht notwendig ist, den Garbage Collector als diesen Troll ohne extreme Notwendigkeit zu füttern. Für sie und um mein Gewissen zu beruhigen, habe ich eine komplexere Lösung vorbereitet:

 /**    */ public partial class Model { public void ExportAll(ExportMode mode, Type propertyType, JSONWriter writer, bool newModel = false); public bool DetectChanges(ExportMode mode, Stack<Model> ierarchyChanged = null); public void ExportChanges(ExportMode mode, Type propertyType, JSONWriter writer, Queue<Model> ierarchyChanges = null); } 

Die Essenz dieser Methode besteht darin, zweimal durch den Baum zu gehen. Zeigen Sie zum ersten Mal alle Modelle an, die sich selbst geändert haben oder Änderungen an untergeordneten Modellen vorgenommen haben, und schreiben Sie sie alle in Queue <Modell> ierarchyChanges genau in der Reihenfolge, in der sie im aktuellen Status im Baum angezeigt werden. Es gibt nicht viele Änderungen, die Warteschlange wird nicht lang sein. Darüber hinaus hindert nichts daran, Stack <Modell> und Queue <Modell> zwischen Anrufen beizubehalten, und dann werden während des Anrufs nur sehr wenige Zuweisungen vorgenommen.

Wenn Sie bereits das zweite Mal durch den Baum gehen, können Sie jedes Mal oben in der Warteschlange nachsehen, ob Sie in diesen Ast des Baums gehen oder sofort weitermachen müssen. Dadurch kann JSONWriter sofort schreiben, ohne andere Zwischenergebnisse zurückzugeben.

Es ist sehr wahrscheinlich, dass diese Komplikation nicht wirklich notwendig ist, da Sie später sehen werden, dass Sie beim Exportieren von Änderungen in den Baum nur zum Debuggen oder beim Absturz mit Exception benötigen. Während des normalen Betriebs ist alles auf GetHashCode (ExportMode-Modus, out int code) beschränkt, dem all diese Freuden zutiefst fremd sind.

Bevor wir unser Modell weiter komplizieren, lassen Sie uns darüber sprechen.

Warum ist es so wichtig?


Alle Programmierer sagen, dass dies schrecklich wichtig ist, aber normalerweise glaubt ihnen niemand. Warum?

Erstens, weil alle Programmierer sagen, dass Sie das Alte wegwerfen und das Neue schreiben müssen. Das ist alles, unabhängig von der Qualifikation. Es gibt keine Möglichkeit für das Management, herauszufinden, ob dies zutrifft oder nicht, und Experimente sind normalerweise zu teuer. Der Manager wird gezwungen sein, einen Programmierer auszuwählen und seinem Urteil zu vertrauen. Das Problem ist, dass ein solcher Berater normalerweise derjenige ist, mit dem das Management schon lange zusammenarbeitet, und ihn danach bewertet, ob er seine Ideen verwirklichen konnte. Und all seine besten Ideen sind bereits in der Realität verankert. Dies ist also auch kein idealer Weg, um herauszufinden, wie gut die Ideen anderer Menschen und die unterschiedlichen Ideen sind.

Zweitens bringen 80% aller Handyspiele in ihrem gesamten Leben weniger als 500 US-Dollar ein. Daher hat das Management zu Beginn des Projekts andere Probleme, vor allem die Architektur. Aber die Entscheidungen, die zu Beginn des Projekts getroffen wurden, nehmen die Menschen als Geiseln und lassen sie nicht von sechs Monaten auf drei Jahre los. Das Umgestalten und Umschalten auf andere Ideen in einem bereits funktionierenden Projekt, das auch Kunden hat, ist ein sehr schwieriges, kostspieliges und riskantes Geschäft. Wenn für ein Projekt zu Beginn die Investition von drei Mannmonaten in eine normale Architektur ein unzulässiger Luxus ist, was können Sie dann über die Kosten für die Verzögerung der Aktualisierung mit neuen Funktionen um einige Monate sagen?

Drittens ist nicht bekannt, wie lange die Implementierung dauern wird, auch wenn die Idee, wie sie an sich sein sollte, gut und ideal ist. Die Abhängigkeit der aufgewendeten Zeit von der Kühle des Programmierers ist sehr nicht linear. Der Seigneur erledigt eine einfache Aufgabe nicht viel schneller als der Junior. Vielleicht eineinhalb Mal. Jeder Programmierer hat jedoch seine eigene „Komplexitätsgrenze“, über die seine Wirksamkeit dramatisch hinausgeht. Ich hatte einen Fall in meinem Leben, in dem ich eine ziemlich komplizierte architektonische Aufgabe realisieren musste, und es half nicht, mich ganz auf das Problem zu konzentrieren, das Internet im Haus auszuschalten und einen Monat lang Fertiggerichte zu bestellen. Aber zwei Jahre später, nachdem ich interessante Bücher gelesen und verwandte Aufgaben gelöst hatte Ich habe dieses Problem in drei Tagen gelöst. Ich bin sicher, jeder wird sich in seiner Karriere an so etwas erinnern. Und hier ist der Haken! Tatsache ist, dass, wenn Ihnen eine geniale Idee in den Sinn kam, wie sie sein sollte, diese neue Idee höchstwahrscheinlich irgendwo an Ihrer persönlichen Komplexitätsgrenze liegt und vielleicht sogar ein wenig dahinter steckt. Das Management, das wiederholt darauf gebrannt hat, beginnt, neue Ideen in die Luft zu jagen. Und wenn Sie das Spiel für sich selbst machen, kann das Ergebnis noch schlechter sein, denn es wird niemanden geben, der Sie aufhält.

Aber wie schafft es jemand überhaupt, gute Lösungen zu verwenden? Es gibt verschiedene Möglichkeiten.

Erstens möchte jedes Unternehmen eine fertige Person einstellen, die dies bereits bei einem früheren Arbeitgeber getan hat. Dies ist der häufigste Weg, um die Last des Experimentierens auf jemand anderen zu verlagern.

Zweitens sind Unternehmen oder Personen, die ihr erstes erfolgreiches Spiel gemacht, geschlürft und das nächste Projekt gestartet haben, bereit für Änderungen.

Drittens, geben Sie sich ehrlich zu, dass Sie manchmal etwas tun, nicht um des Gehalts willen, sondern um das Vergnügen des Prozesses. Die Hauptsache ist, Zeit dafür zu finden.

Viertens sind es eine Reihe bewährter Lösungen und Bibliotheken sowie Menschen, die die Hauptmittel des Glücksspielunternehmens ausmachen, und dies ist das einzige, was darin verbleibt, wenn eine Schlüsselperson aus Australien ausscheidet und nach Australien zieht.

Der allerletzte, wenn auch nicht der offensichtlichste Grund: weil es furchtbar vorteilhaft ist. Gute Lösungen führen zu einer mehrfachen Verkürzung der Zeit, um neue Funktionen zu schreiben, zu debuggen und Fehler zu erkennen. Lassen Sie mich ein Beispiel geben: Vor zwei Tagen hatte der Client eine Ausführung in einer neuen Funktion, deren Wahrscheinlichkeit 1 von 1000 ist, dh die Qualitätssicherung wird zur Reproduktion gefoltert, und wenn Sie sie geben, sind es 200 Fehlermeldungen pro Tag. Wie viel Zeit benötigen Sie, um die Situation zu reproduzieren und den Client am Haltepunkt einer Zeile zu fangen, bevor alles zusammenbricht? Zum Beispiel habe ich 10 Minuten.

Modell


Modellbaum


Das Modell besteht aus vielen Objekten. Verschiedene Programmierer entscheiden unterschiedlich, wie sie miteinander verbunden werden sollen. Der erste Weg ist, wenn das Modell durch den Ort identifiziert wird, an dem es liegt. Dies ist sehr praktisch und einfach, wenn der Verweis auf das Modell zu einer einzelnen Stelle in ModelRoot gehört. Vielleicht kann es sogar von Ort zu Ort verschoben werden, aber zwei Verbindungen von verschiedenen Orten führen nie dazu. Dazu werden wir eine neue Version des ModelProperty-Deskriptors einführen, die sich mit Links von einem Modell zu anderen darin befindlichen Modellen befasst. Im Code sieht es folgendermaßen aus:

 public class PModel<T> : Property<T> where T:Model {} public partial class PlayerModel : Model { public PModel<InventoryModel> INVENTORY = new PModel<InventoryModel>(); public InventoryModel inventory { get { return INVENTORY.Value(this); } set { INVENTORY.Value(this, value); } } } 

Was ist der Unterschied?Wenn diesem Feld ein neues Modell hinzugefügt wird, wird das Modell, in das es hinzugefügt wurde, in das übergeordnete Feld geschrieben. Wenn es gelöscht wird, wird das übergeordnete Feld zurückgesetzt. Theoretisch ist alles in Ordnung, aber es gibt viele Fallstricke. Die ersten Programmierer, die es verwenden, können sich irren. Um dies zu vermeiden, führen wir versteckte Überprüfungen dieses Prozesses aus verschiedenen Blickwinkeln durch:

  1. Wir werden PValue so korrigieren, dass es den Typ seines Werts überprüft, und schwören auf die Experten, wenn sie versuchen, einen Verweis auf das Modell darin zu speichern, was darauf hinweist, dass hierfür eine andere Konstruktion verwendet werden muss, damit sie nicht verwechselt werden. Dies ist natürlich eine Laufzeitprüfung, aber sie schwört beim ersten Startversuch, also wird es tun.
  2. PModel Parent - , . . , .

Daraus ergibt sich ein Nebeneffekt: Wenn Sie ein solches Modell von einem Ort zum anderen verschieben müssen, müssen Sie es zuerst vom ersten Ort entfernen und erst dann zum zweiten hinzufügen - andernfalls schimpfen Sie bei den Überprüfungen. Das kommt aber eigentlich recht selten vor.

Da das Modell an einer genau definierten Stelle liegt und einen Verweis auf sein übergeordnetes Element hat, können wir ihm eine neue Methode hinzufügen - es kann erkennen, wie es sich im ModelRoot-Baum befindet. Dies ist äußerst praktisch für das Debuggen, wird jedoch auch benötigt, damit es eindeutig identifiziert werden kann. Suchen Sie beispielsweise ein anderes genau dasselbe Modell in einem anderen gleichen Spielzustand oder geben Sie in dem an den Server übertragenen Befehl einen Link an, zu welchem ​​Modell der Befehl enthält. Es sieht ungefähr so ​​aus:

 public class ModelPath { public Property[] properties; public Object[] indexes; public override ToString(); public static ModelPath FromString(string path); } public partial class Model { public ModelPath Path(); } public partial class ModelRoot : Model { public Model GetByPath(ModelPath path); } 

Und warum ist es tatsächlich unmöglich, ein Objekt an einem Ort zu verwurzeln, sondern von einem anderen auf es zu verweisen? Aber weil Sie sich vorstellen, ein Objekt von JSON zu deserialisieren, finden Sie hier einen Link zu einem Objekt, das an einem völlig anderen Ort verwurzelt ist. Und dafür gibt es noch keinen Platz, es wird nur durch den Boden der Deserialisierung geschaffen. Ups Bitte bieten Sie keine Deserialisierung mit mehreren Durchgängen an. Dies ist die Einschränkung dieser Methode. Daher werden wir eine zweite Methode entwickeln:

Alle Modelle, die mit der zweiten Methode erstellt wurden, werden an einem magischen Ort erstellt, und an allen anderen Orten des Gamestate werden nur Links zu ihnen eingefügt. Wenn während der Deserialisierung beim ersten Zugriff auf den magischen Ort mehrere Verweise auf das Objekt vorhanden sind, wird das Objekt erstellt und mit allen nachfolgenden Verweisen auf dasselbe Objekt zurückgegeben. Um andere Funktionen zu implementieren, gehen wir davon aus, dass das Spiel mehrere Spielzustände haben kann, sodass der magische Ort nicht ein gemeinsamer sein sollte, sondern sich beispielsweise im Spielzustand befinden sollte. Für Verweise auf solche Modelle verwenden wir eine andere Variante des PPersistent-Deskriptors. Das Modell selbst wird durch Persistent: Model spezieller. Im Code sieht es ungefähr so ​​aus:

 public class Persistent : Model { public int id { get { return ID.Get(this); } set { ID.Set(this, value); } } public static RProperty<int> ID = new RProperty<int>(); } public partial class ModelRoot : Model { public int nextFreePersistentId { get { return NEXT_FREE_PERSISTENT_ID.Get(this); } set { NEXT_FREE_PERSISTENT_ID.Set(this, value); } } public static RProperty<int> NEXT_FREE_PERSISTENT_ID = new RProperty<int>(); public static PDictionaryModel<int, Persistent> PERSISTENT = new PDictionaryModel<int, Persistent>() { notServerVerified = true }; /// <summary>      Id-. </summary> public PersistentT Persistent<PersistentT>(int localId) where PersistentT : Persistent, new(); /// <summary> C    Id. </summary> public PersistentT Persistent<PersistentT>() where PersistentT : Persistent, new(); } 

Ein wenig umständlich, aber es kann verwendet werden. Um Strohhalme zu legen, kann Persistent den Konstruktor mit dem Parameter ModelRoot befestigen, der einen Alarm auslöst, wenn versucht wird, dieses Modell nicht mit den Methoden dieses ModelRoot zu erstellen.

Ich habe beide Optionen in meinem Code und die Frage ist, warum dann die erste Option verwenden, wenn die zweite alle möglichen Fälle vollständig abdeckt.

Die Antwort ist, dass der Stand des Spiels vor allem für die Menschen lesbar sein sollte. Wie sieht es aus, wenn wenn möglich die erste Option verwendet wird?

 { "persistents":{}, "player":{ "money":10, "inventory":{"capacity":11} } } 

Und jetzt, wie würde es aussehen, wenn nur die zweite Option verwendet würde:
 { "persistents":{ "1":{"money":10, "inventory":2}, "2":{"capacity":11} }, "player":1 } 

Zum persönlichen Debuggen bevorzuge ich die erste Option.

Greifen Sie auf Modelleigenschaften zu


Der Zugang zu reaktiven Lagereinrichtungen für Immobilien erwies sich am Ende als unter der Motorhaube des Modells verborgen. Es ist nicht allzu offensichtlich, wie es so schnell funktioniert, ohne zu viel Code in den endgültigen Modellen und ohne zu viel Reflexion. Schauen wir uns das genauer an.

Das erste, was Sie über Dictionary wissen sollten, ist, dass das Lesen nicht so viel konstante Zeit in Anspruch nimmt, unabhängig von der Größe des Wörterbuchs. Wir werden in Model ein privates statisches Wörterbuch erstellen, in dem jedem Modelltyp eine Beschreibung der darin enthaltenen Felder zugewiesen wird, und wir werden beim Erstellen des Modells einmal darauf zugreifen. Im Typkonstruktor prüfen wir, ob es eine Beschreibung für unseren Typ gibt. Wenn nicht, erstellen wir sie. Wenn ja, nehmen wir die fertige. Daher wird die Beschreibung für jede Klasse nur einmal erstellt. Beim Erstellen einer Beschreibung fügen wir in jede statische Eigenschaft (Feldbeschreibung) die durch Reflexion extrahierten Daten ein - den Namen des Felds und den Index, unter dem sich der Datenspeicher für dieses Feld im Array befindet. Auf diese Weise,Beim Zugriff über die Feldbeschreibung wird der Speicher an einem zuvor bekannten Index, dh schnell, aus dem Array entfernt.

Im Code sieht es folgendermaßen aus:

 public class Model : IModelInternals { #region Properties protected static Dictionary<Type, Property[]> propertiesDictionary = new Dictionary<Type, Property[]>(); protected static Dictionary<Type, Property[]> propertiesForBinarySerializationDictionary = new Dictionary<Type, Property[]>(); protected Property[] _properties, _propertiesForBinarySerialization; protected BaseStorage[] _storages; public Model() { Type targetType = GetType(); if (!propertiesDictionary.ContainsKey(targetType)) RegisterModelsProperties(targetType, new List<Property>(), new List<Property>()); _properties = propertiesDictionary[targetType]; _storages = new BaseStorage[_properties.Length]; for (var i = 0; i < _storages.Length; i++) _storages[i] = _properties[i].CreateStorage(); } private void RegisterModelsProperties(Type target, List<Property> registered, List<Property> registeredForBinary) { if (!propertiesDictionary.ContainsKey(target)) { if (target.BaseType != typeof(Model) && typeof(Model).IsAssignableFrom(target.BaseType)) RegisterModelsProperties(target.BaseType, registered, registeredForBinary); var fields = target.GetFields(BindingFlags.Public | BindingFlags.Static); // | BindingFlags.DeclaredOnly List<Property> alphabeticSorted = new List<Property>(); for (int i = 0; i < fields.Length; i++) { var field = fields[i]; if (typeof(Property).IsAssignableFrom(field.FieldType)) { var prop = field.GetValue(this) as Property; prop.Name = field.Name; prop.Parent = target; prop.storageIndex = registered.Count; registered.Add(prop); alphabeticSorted.Add(prop); } } alphabeticSorted.Sort((p1, p2) => String.Compare(p1.Name, p2.Name)); registeredForBinary.AddRange(alphabeticSorted); Property[] properties = new Property[registered.Count]; for (int i = 0; i < registered.Count; i++) properties[i] = registered[i]; propertiesDictionary.Add(target, properties); properties = new Property[registered.Count]; for (int i = 0; i < registeredForBinary.Count; i++) properties[i] = registeredForBinary[i]; propertiesForBinarySerializationDictionary.Add(target, properties); } else { registered.AddRange(propertiesDictionary[target]); registeredForBinary.AddRange(propertiesForBinarySerializationDictionary[target]); } } CastType IModelInternals.GetStorage<CastType>(Property property) { try { return (CastType)_storages[property.storageIndex]; } catch { UnityEngine.Debug.LogError(string.Format("{0}.GetStorage<{1}>({2})",GetType().Name, typeof(CastType).Name, property.ToString())); return null; } } #endregion } 

Das Design ist ein wenig einfach, da die in den Vorfahren dieses Modells deklarierten statischen Eigenschaftsbeschreibungen möglicherweise bereits registrierte Speicherindizes haben und die Reihenfolge der Rückgabe von Eigenschaften von Type.GetFields () nicht garantiert ist. Für die Reihenfolge und damit die Eigenschaften nicht in zwei Teile neu initialisiert werden Mal müssen Sie sich selbst überwachen.

Sammlungseigenschaften


Im Abschnitt des Modellbaums konnte man eine Konstruktion bemerken, die zuvor nicht erwähnt wurde: PDictionaryModel <int, Persistent> - ein Deskriptor für ein Feld, das eine Sammlung enthält. Es ist klar, dass wir ein eigenes Repository für Sammlungen erstellen müssen, in dem Informationen darüber gespeichert sind, wie die Sammlung vor dem Start der Transaktion ausgesehen hat und wie sie jetzt aussieht. Der Unterwasserkiesel hier hat die Größe eines Donnersteins unter Peter I. Er besteht darin, dass es mit zwei langen Wörterbüchern eine höllisch teure Aufgabe ist, den Unterschied zwischen ihnen zu berechnen. Ich gehe davon aus, dass solche Modelle für alle Aufgaben im Zusammenhang mit Meta verwendet werden sollten, was bedeutet, dass sie schnell funktionieren sollten. Anstatt zwei Status zu speichern, sie zu klonen und sie dann teuer zu vergleichen, mache ich einen kniffligen Haken - nur der aktuelle Status des Wörterbuchs wird im Speicher gespeichert. Weitere zwei Wörterbücher sind gelöschte Werte.und alte Werte der ersetzten Elemente. Schließlich wird ein Satz neuer Schlüssel gespeichert, die dem Wörterbuch hinzugefügt wurden. Diese Informationen werden einfach und schnell ausgefüllt. Es ist einfach, alle erforderlichen Unterschiede damit zu generieren, und es reicht aus, den vorherigen Status bei Bedarf wiederherzustellen. Im Code sieht es so aus:

 public class DictionaryStorage<TKey, TValues> : BaseStorage { public Dictionary<TKey, TValues> current = new Dictionary<TKey, TValues>(); public Dictionary<TKey, TValues> removed = new Dictionary<TKey, TValues>(); public Dictionary<TKey, TValues> changedValues = new Dictionary<TKey, TValues>(); public HashSet<TKey> newKeys = new HashSet<TKey>(); } 

Es ist mir nicht gelungen, ein ebenso schönes Repository für die Liste zu erstellen, oder ich habe nicht genug Zeit, ich behalte zwei Kopien. Ein zusätzliches Add-On ist erforderlich, um die Größe des Diff zu minimieren.

 public class ListStorage<TValue> : BaseStorage { public List<TValue> current = new List<TValue>(); public List<TValue> previouse = new List<TValue>(); //        public List<int> order = new List<int>(); //       . } 

Insgesamt


Wenn Sie genau wissen, was Sie erhalten möchten und wie, können Sie dies alles in wenigen Wochen schreiben. Die Entwicklungsgeschwindigkeit des Spiels ändert sich gleichzeitig so dramatisch, dass ich beim Ausprobieren nicht einmal meine eigenen Spiele ohne eine gute Engine gestartet habe. Nur weil sich die Investition im ersten Monat für mich offensichtlich gelohnt hat. Dies gilt natürlich nur für Meta. Das Gameplay muss auf die altmodische Weise gemacht werden.

Im nächsten Teil des Artikels werde ich über Befehle, Netzwerke und die Vorhersage von Serverantworten sprechen. Und ich habe auch einige Fragen an Sie, die mir sehr wichtig sind. Wenn Ihre Antworten von den in Klammern angegebenen abweichen, lese ich sie gerne in den Kommentaren oder schreiben Sie vielleicht sogar einen Artikel. Vielen Dank im Voraus für die Antworten.

PS Ein Vorschlag zur Zusammenarbeit und Anweisungen zu zahlreichen Syntaxfehlern, bitte in PM.

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


All Articles