Solutions architecturales pour un jeu mobile. Partie 1: Modèle

Épigraphe:
- Comment vais-je évaluer si vous ne savez pas quoi faire?
- Eh bien, il y aura des écrans et des boutons.
- Dima, tu viens de décrire ma vie entière en trois mots!
(c) Un vrai dialogue lors d'un rassemblement dans une entreprise de jeux



L'ensemble des besoins et les solutions qui y répondent, dont je parlerai dans cet article, s'est formé lors de ma participation à une dizaine de grands projets, d'abord sur Flash puis sur Unity. Le plus grand des projets comptait plus de 200 000 DAU et a complété ma tirelire avec de nouveaux défis originaux. En revanche, la pertinence et la nécessité des résultats antérieurs ont été confirmées.

Dans notre dure réalité, tous ceux qui ont au moins une fois conçu un grand projet au moins dans leurs pensées ont leurs propres idées sur la façon de le faire et sont souvent prêts à défendre leurs idées jusqu'à la dernière goutte de sang. Pour d'autres, cela me fait sourire, et la direction considère souvent tout cela comme une énorme boîte noire, qui ne repose sur personne. Mais que se passe-t-il si je vous dis que les bonnes solutions aideront à réduire la création de nouvelles fonctionnalités de 2-3 fois, la recherche d'erreurs dans les anciens 5 à 10 fois et vous permettront de faire de nombreuses choses nouvelles et importantes qui n'étaient pas disponibles du tout auparavant? Il suffit de laisser entrer l'architecture dans votre cœur!
Solutions architecturales pour un jeu mobile. Partie 2: commande et leurs files d'attente
Solutions architecturales pour un jeu mobile. Partie 3: Vue sur la poussée du jet


Modèle


Accès aux champs


La plupart des programmeurs reconnaissent l'importance d'utiliser quelque chose comme MVC. Peu de gens utilisent le MVC pur du livre d'un gang de quatre, mais toutes les décisions des bureaux normaux sont en quelque sorte similaires à ce modèle dans l'esprit. Aujourd'hui, nous allons parler de la première des lettres de cette abréviation. Parce qu'une grande partie du travail des programmeurs dans un jeu mobile concerne les nouvelles fonctionnalités du méta-jeu, implémentées sous forme de manipulations avec le modèle, et vissant des milliers d'interfaces dans ces fonctionnalités. Et la commodité du modèle joue un rôle clé dans cette leçon.

Je ne fournis pas le code complet, car c'est un petit dofig, et en général ce n'est pas à propos de lui. J'illustrerai mon raisonnement par un exemple simple:

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

Cette option ne nous convient pas du tout, car le modèle n'envoie pas d'événements sur les changements qui s'y produisent. Si des informations sur les champs qui ont été affectés par les modifications, celles qui ne le sont pas, celles qui doivent être redessinées et celles qui ne le sont pas, le programmeur l'indiquera manuellement sous une forme ou une autre - cela deviendra la principale source d'erreurs et de temps. Et il suffit de ne pas avoir les yeux surpris. Dans la plupart des grands bureaux dans lesquels j'ai travaillé, le programmeur a lui-même envoyé toutes sortes d'InventoryUpdatedEvent et, dans certains cas, les a également remplis manuellement. Certains de ces bureaux ont fait des millions, pensez-vous, merci ou malgré?

Nous utiliserons notre propre classe ReactiveProperty <T> qui cachera sous le capot toutes les manipulations pour envoyer les messages dont nous avons besoin. Cela ressemblera à ceci:

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

Ceci est la première version du modèle. Cette option est déjà un rêve pour de nombreux programmeurs, mais je ne l'aime toujours pas. La première chose que je n'aime pas, c'est que l'accès aux valeurs est compliqué. J'ai réussi à être confus en écrivant cet exemple, en oubliant Value en un seul endroit, et ce sont précisément ces manipulations de données qui constituent la part du lion de tout ce qui est fait et confondu avec le modèle. Si vous utilisez la version linguistique 4.x, vous pouvez le faire:

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

mais cela ne résout pas tous les problèmes. Je voudrais écrire simplement: inventaire.capacité ++;. Supposons que nous essayons d'obtenir pour chaque champ de modèle; ensemble; Mais pour souscrire à des événements, nous avons également besoin d'accéder à ReactiveProperty lui-même. Clarté des inconvénients et source de confusion. Malgré le fait qu'il nous suffit d'indiquer quel champ nous allons surveiller. Et ici, je suis venu avec une manœuvre délicate que j'ai aimé.

Voyons voir si vous l'aimez.

Ce n'est pas ReactiveProperty qui est inséré dans le modèle concret avec lequel le programmeur traite, il est inséré, mais son descripteur statique PValue, l'héritier de la propriété plus générale, il identifie le champ, et à l'intérieur sous le capot du constructeur du modèle est caché la création et le stockage de la ReactiveProperty du type souhaité. Pas le meilleur nom, mais c'est arrivé, puis renommé.

En code, cela ressemble à ceci:

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

Ceci est la deuxième option. L'ancêtre général du modèle, bien sûr, était compliqué au détriment de la création et de l'extraction d'une véritable propriété réactive selon son descripteur, mais cela peut se faire très rapidement et sans réflexion, ou plutôt, appliquer la réflexion une seule fois au stade de l'initialisation de la classe. Et c'est le travail qui est fait une fois par le créateur du moteur, puis il sera utilisé par tout le monde. De plus, cette conception évite les tentatives accidentelles de manipuler ReactiveProperty lui-même au lieu des valeurs qui y sont stockées. La création du champ est encombrée, mais dans tous les cas, c'est exactement la même chose, et elle peut être créée avec un modèle.

À la fin de l'article, il y a un sondage sur l'option que vous préférez.
Tout ce qui est décrit ci-dessous peut être implémenté dans les deux versions.

Les transactions


Je veux que les programmeurs puissent modifier les champs du modèle uniquement lorsque cela est autorisé par les restrictions adoptées dans le moteur, c'est-à-dire à l'intérieur de l'équipe, et plus jamais. Pour ce faire, le setter doit aller quelque part et vérifier si la commande de transaction est actuellement ouverte, et ensuite seulement autoriser la modification des informations dans le modèle. C'est très nécessaire, car les utilisateurs du moteur essaient régulièrement de faire quelque chose d'étrange pour contourner un processus typique, brisant la logique du moteur et provoquant des erreurs subtiles. Je l'ai vu plus d'une ou deux fois.

On pense que si vous créez une interface distincte pour lire les données du modèle et pour les écrire, cela vous aidera en quelque sorte. En réalité, le modèle regorge de fichiers supplémentaires et d'opérations supplémentaires fastidieuses. Ces restrictions sont définitives. Les programmeurs sont obligés, premièrement, de les connaître et d'y penser constamment: «ce que chaque fonction, modèle ou interface spécifique doit donner», et deuxièmement, des situations se produisent également lorsque ces restrictions doivent être contournées, donc à la sortie, nous avons d'Artagnan, qui est venu avec tout cela en blanc, et de nombreux utilisateurs de son moteur, qui sont de mauvais gardes du chef de projet, et malgré les abus constants, rien ne fonctionne comme prévu. Par conséquent, je préfère bloquer étroitement la possibilité d'une telle erreur. Réduisez la dose de conventions, pour ainsi dire.

Le setter ReactiveProperty doit avoir un lien vers l'endroit où l'état actuel de la transaction doit être vérifié. Disons que cet endroit est classCModelRoot. L'option la plus simple consiste à la transmettre explicitement au constructeur du modèle. La deuxième version du code lors de l'appel à RProperty reçoit explicitement un lien vers cela et peut y obtenir toutes les informations nécessaires. Pour la première version du code, vous devrez parcourir les champs du type ReactiveProperty dans le constructeur avec une réflexion et leur donner un lien vers cela pour d'autres manipulations. Un léger inconvénient est la nécessité de créer un constructeur explicite avec un paramètre dans chaque modèle, quelque chose comme ceci:

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

Mais pour d'autres caractéristiques des modèles, il est très utile que le modèle ait un lien avec le modèle parent, formant une construction biconnectée. Dans notre exemple, ce sera player.inventory.Parent == player. Et puis ce constructeur peut être évité. Tout modèle pourra obtenir et mettre en cache un lien vers un endroit magique de son parent, et celui de son parent, et ainsi de suite jusqu'à ce que le parent suivant se révèle être cet endroit magique. En conséquence, au niveau des déclarations, tout cela ressemblera à ceci:

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

Toute cette beauté sera remplie automatiquement lorsque le modèle entrera dans l'arbre de jeu. Oui, le modèle nouvellement créé, qui n'est pas encore arrivé, ne pourra pas en savoir plus sur la transaction et bloquer les manipulations avec lui-même, mais si l'état de la transaction est interdit, il ne pourra pas entrer dans l'état après cela, le passeur du futur parent ne le permettra pas, de sorte que l'intégrité du gamestate ne sera pas affectée. Oui, cela nécessitera un travail supplémentaire au stade de la programmation du moteur, mais d'un autre côté, un programmeur utilisant le moteur éliminera complètement le besoin de le savoir et d'y penser jusqu'à ce qu'il essaie de faire quelque chose de mal et se fasse prendre par les mains.

Puisque la conversation sur la transactivité a commencé, les messages sur les modifications ne doivent pas être traités immédiatement après la modification, mais uniquement lorsque toutes les manipulations avec le modèle dans la commande en cours sont terminées. Il y a deux raisons à cela, la première est la cohérence des données. Tous les états de données ne sont pas cohérents en interne. Vous ne pouvez peut-être pas essayer de les rendre. Ou si vous êtes impatient, par exemple, de trier un tableau ou de changer une variable de modèle dans une boucle. Vous ne devriez pas recevoir des centaines de messages de modification.

Il y a deux façons de procéder. La première consiste à s'abonner aux mises à jour d'une variable et à utiliser une fonction délicate qui ajoute un flux de terminaisons de transaction au flux de modifications de la variable et ne fera qu'ignorer les messages par la suite. C'est assez facile à faire si vous utilisez UniRX, par exemple. Mais cette option présente de nombreuses lacunes, en particulier elle donne lieu à de nombreux mouvements inutiles. Personnellement, j'aime l'autre option.

Chaque ReactiveProperty se souviendra de son état avant le début de la transaction et de son état actuel. Un message sur la modification et la fixation des modifications ne sera effectué qu'à la fin de la transaction. Dans le cas où l'objet de la modification était une sorte de collection, cela permettra explicitement d'inclure des informations sur les modifications qui se sont produites dans le message envoyé. Par exemple, ces deux éléments de la liste ont été ajoutés et supprimés. Au lieu de simplement dire que quelque chose a changé et d'obliger le destinataire à analyser une liste de mille éléments en longueur à la recherche d'informations à redessiner.

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

L'option prend plus de temps au stade de la création du moteur, mais le coût d'utilisation est alors plus faible. Et surtout, cela ouvre la possibilité de la prochaine amélioration.

Informations sur les modifications apportées au modèle


Je veux plus du modèle. À tout moment, je veux voir facilement et commodément ce qui a changé dans l'état du modèle à la suite de mes actions. Par exemple, sous cette forme:

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

Le plus souvent, il est utile pour le programmeur de voir la différence entre l'état du modèle avant le début de la commande et après sa fin, ou à un moment donné à l'intérieur de la commande. Certains pour ce clone l'ensemble du gamestate avant le début de l'équipe, puis comparer. Cela résout partiellement le problème au stade du débogage, mais il est absolument impossible de l'exécuter dans le produit. Ce clonage d'état, le calcul de la différence insignifiante entre les deux listes, est une opération monstrueusement chère à faire avec n'importe quel éternuement.

Par conséquent, ReactiveProperty doit stocker non seulement son état actuel, mais également le précédent. Cela donne lieu à tout un groupe d'opportunités extrêmement utiles. Premièrement, l'extraction de la différence dans une telle situation est rapide et nous pouvons tranquillement tout jeter dans la nourriture. Deuxièmement, vous pouvez obtenir non pas un diff encombrant, mais un petit hachage compact à partir des changements, et le comparer avec un hachage de changements dans un autre même gamestate. Si ce n'est pas d'accord, vous avez des problèmes. Troisièmement, si l'exécution de la commande a échoué avec l'exécution, vous pouvez toujours annuler les modifications et vous renseigner sur l'état intact au moment où la transaction a commencé. Avec l'équipe appliquée à l'état, ces informations sont inestimables car vous pouvez facilement reproduire la situation avec précision. Bien sûr, pour cela, vous devez disposer de fonctionnalités prêtes à l'emploi pour une sérialisation et une désérialisation pratiques de l'état du jeu, mais vous en aurez quand même besoin.

Sérialisation des changements de modèle


Le moteur fournit la sérialisation et binaire, et en json - et ce n'est pas un hasard. Bien sûr, la sérialisation binaire prend beaucoup moins d'espace et fonctionne beaucoup plus rapidement, ce qui est important, en particulier lors du démarrage initial. Mais ce n'est pas un format lisible par l'homme, et ici nous prions pour la commodité du débogage. De plus, il y a un autre écueil. Lorsque votre jeu passe en prod, vous devrez constamment passer d'une version à l'autre. Si vos programmeurs suivent quelques précautions simples et ne suppriment rien de l'état du jeu inutilement, vous ne ressentirez pas cette transition. Et au format binaire, il n'y a pas de noms de chaîne de champ pour des raisons évidentes, et si les versions ne correspondent pas, vous devrez lire le binaire avec l'ancienne version de l'état, l'exporter vers quelque chose de plus informatif, par exemple, le même json, puis l'importer dans un nouvel état, l'exporter vers le binaire, écrivez, et seulement après tout ce travail, comme d'habitude. Par conséquent, dans certains projets, les configurations sont écrites dans des fichiers binaires compte tenu de leurs tailles cyclopéennes, et ils préfèrent déjà faire glisser l'état d'avant en arrière sous la forme de json. Évaluez les frais généraux et choisissez-vous.

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

La signature de la méthode Export (mode ExportMode, out Dictionary <string, object> data) est quelque peu alarmante. Et la chose est la suivante: lorsque vous sérialisez l'arborescence entière, vous pouvez écrire immédiatement dans le flux, ou dans notre cas, dans JSONWriter, qui est un simple module complémentaire de StringWriter. Mais lorsque vous exportez des modifications, ce n'est pas si simple, car lorsque vous allez profondément dans un arbre et allez dans l'une des branches, vous ne savez toujours pas si vous devez en exporter quoi que ce soit. Par conséquent, à ce stade, j'ai trouvé deux solutions, l'une plus simple, la seconde plus compliquée et économique. Plus simple, lorsque vous exportez uniquement les modifications, vous transformez toutes les modifications en arborescence à partir de Dictionary <string, object> et List <object>. Et puis ce qui s'est passé, alimentez votre sérialiseur préféré. Il s'agit d'une approche simple qui ne nécessite pas de danser avec un tambourin. Mais son inconvénient est que lors du processus d'exportation des modifications vers le tas, une place pour les collections uniques sera allouée. En fait, il n'y a pas beaucoup d'espace, car cette exportation complète donne un grand arbre, et la commande typique laisse très peu de changements dans l'arbre.

Cependant, beaucoup de gens croient que nourrir le ramasse-miettes comme ce troll n'est pas nécessaire sans un besoin extrême. Pour eux, et pour calmer ma conscience, j'ai préparé une solution plus complexe:

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

L'essence de cette méthode est de marcher deux fois dans l'arbre. Pour la première fois, examinez tous les modèles qui ont changé eux-mêmes ou qui ont des changements dans les modèles enfants et écrivez-les tous dans Queue <Model> ierarchyChanges exactement dans l'ordre dans lequel ils apparaissent dans l'arborescence dans son état actuel. Il n'y a pas beaucoup de changements, la file d'attente ne sera pas longue. De plus, rien n'empêche de conserver Stack <Model> et Queue <Model> entre les appels et il y aura alors très peu d'allocations pendant l'appel.

Et déjà en passant la deuxième fois à travers l'arbre, il sera possible de regarder en haut de la file d'attente à chaque fois, et de comprendre s'il est nécessaire d'aller dans cette branche de l'arbre ou de passer immédiatement. Cela permet à JSONWriter d'écrire immédiatement sans renvoyer d'autres résultats intermédiaires.

Il est très probable que cette complication ne soit pas vraiment nécessaire, car plus tard, vous verrez que l'exportation des modifications dans l'arborescence dont vous avez besoin uniquement pour le débogage ou lors d'un plantage avec Exception. Pendant le fonctionnement normal, tout est limité à GetHashCode (mode ExportMode, hors code int) auquel tous ces délices sont profondément étrangers.

Avant de continuer à compliquer notre modèle, parlons-en.

Pourquoi est-ce si important


Tous les programmeurs disent que c'est terriblement important, mais généralement personne ne les croit. Pourquoi?

Premièrement, parce que tous les programmeurs disent que vous devez jeter l'ancien et écrire le nouveau. C'est tout, peu importe les qualifications. Il n'y a aucun moyen managérial de savoir si cela est vrai ou non, et les expériences sont généralement trop chères. Le manager sera obligé de choisir un programmeur et fera confiance à son jugement. Le problème est qu'un tel conseiller est généralement celui avec qui la direction travaille depuis longtemps et l'évalue en fonction de sa capacité à concrétiser ses idées. Et toutes ses meilleures idées sont déjà concrétisées. Ce n'est donc pas non plus un moyen idéal de découvrir à quel point les idées des autres et les idées différentes sont bonnes.

Deuxièmement, 80% de tous les jeux mobiles rapportent moins de 500 $ dans toute leur vie. Par conséquent, au début du projet, la gestion a d'autres problèmes, surtout l'architecture. Mais les décisions prises au tout début du projet prennent les gens en otage et ne laissent pas passer de six mois à trois ans. Le processus de refactorisation et de passage à d'autres idées dans un projet déjà en cours, qui a également des clients, est une entreprise très difficile, coûteuse et risquée. Si pour un projet au tout début, investir trois mois-homme dans une architecture normale semble être un luxe inadmissible, que pouvez-vous dire du coût du retard de la mise à jour avec de nouvelles fonctionnalités pendant quelques mois?

Troisièmement, même si l'idée de «comment cela devrait être» est en soi bonne et idéale, on ne sait pas combien de temps sa mise en œuvre prendra. La dépendance du temps passé sur la fraîcheur du programmeur est très non linéaire. Le seigneur fera une tâche simple pas beaucoup plus vite que le cadet. Une fois et demie, peut-être. Mais chaque programmeur a sa propre «limite de complexité», au-delà de laquelle son efficacité chute de façon spectaculaire. J'ai eu un cas dans ma vie où j'avais besoin de réaliser une tâche architecturale assez compliquée, et même se concentrer complètement sur le problème de désactiver Internet dans la maison et de commander des plats cuisinés pendant un mois n'a pas aidé. Mais deux ans plus tard, après avoir lu des livres intéressants et résolu des tâches connexes , J'ai résolu ce problème en trois jours. Je suis sûr que tout le monde se souviendra de quelque chose comme ça dans sa carrière. Et voici le hic! Le fait est que si une idée ingénieuse vous vient à l'esprit comme il se doit, alors cette nouvelle idée se situe probablement quelque part dans votre limite personnelle de complexité, et peut-être même un peu derrière. La direction, après avoir brûlé à maintes reprises, commence à souffler sur de nouvelles idées. Et si vous faites le jeu par vous-même, le résultat peut être encore pire, car il n'y aura personne pour vous arrêter.

Mais comment, alors, quelqu'un parvient-il à utiliser de bonnes solutions? Il y a plusieurs façons.

Tout d'abord, chaque entreprise souhaite embaucher une personne prête à l'emploi qui l'a déjà fait avec un employeur précédent. C'est le moyen le plus courant de transférer le fardeau de l'expérimentation sur quelqu'un d'autre.

Deuxièmement, les entreprises ou les personnes qui ont réussi leur premier jeu réussi, se sont élancées et ont commencé le prochain projet sont prêtes à changer.

Troisièmement, admettez-vous honnêtement que parfois vous faites quelque chose non pas pour le salaire, mais pour le plaisir du processus. L'essentiel est de trouver du temps pour cela.

Quatrièmement, c'est un ensemble de solutions et de bibliothèques éprouvées, ainsi que des personnes, qui constituent les principaux fonds de la société de jeux, et c'est la seule chose qui y restera lorsqu'une personne clé quittera et déménagera en Australie.

La toute dernière, mais pas la raison la plus évidente: car elle est terriblement bénéfique. Les bonnes solutions entraînent une réduction multiple du temps d'écriture de nouvelles fonctionnalités, de débogage et de détection des erreurs. Permettez-moi de vous donner un exemple: il y a deux jours, le client a eu une exécution dans une nouvelle fonctionnalité, dont la probabilité est de 1 sur 1000, c'est-à-dire que le contrôle qualité sera torturé pour se reproduire, et lorsque vous le donnez, c'est 200 messages d'erreur par jour. Combien de temps cela vous prendra-t-il pour reproduire la situation et attraper le client au point d'arrêt une ligne avant que tout ne s'effondre? Par exemple, j'ai 10 minutes.

Modèle


Arbre modèle


Le modèle se compose de nombreux objets. Différents programmeurs décident différemment de la façon de les connecter ensemble. La première façon est lorsque le modèle est identifié par l'endroit où il se trouve. Ceci est très pratique et simple lorsque la référence au modèle appartient à un seul endroit dans ModelRoot. Peut-être même qu'il peut être déplacé d'un endroit à l'autre, mais deux liens de différents endroits n'y mènent jamais. Nous le ferons en introduisant une nouvelle version du descripteur ModelProperty qui traitera des liens d'un modèle à d'autres modèles qui y sont situés. Dans le code, cela ressemblera à ceci:

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

Quelle est la différence? Lorsqu'un nouveau modèle est ajouté à ce champ, le modèle dans lequel il a été ajouté est écrit dans son champ parent et lorsqu'il est supprimé, le champ parent est réinitialisé. En théorie, tout va bien, mais il y a de nombreux pièges. Les premiers - les programmeurs qui l'utiliseront, peuvent se tromper. Pour éviter cela, nous imposons des contrôles cachés sur ce processus, sous différents angles:

  1. Nous allons corriger PValue afin qu'il vérifie le type de sa valeur et jure par les experts lorsqu'ils essaient de stocker une référence au modèle, indiquant que pour cela, il est nécessaire d'utiliser une construction différente, juste pour ne pas être confondu. Ceci, bien sûr, est une vérification de l'exécution, mais il ne jure qu'à la toute première tentative de démarrage, ce sera donc le cas.
  2. PModel Parent - , . . , .

Un effet secondaire en découle, si vous devez déplacer un tel modèle d'un endroit à un autre, vous devez d'abord le retirer du premier endroit, puis l'ajouter ensuite au second - sinon les contrôles vous gronderont. Mais cela arrive en fait assez rarement.

Puisque le modèle se trouve à un endroit strictement défini et a une référence à son parent, nous pouvons lui ajouter une nouvelle méthode - il peut indiquer de quelle manière il se trouve dans l'arbre ModelRoot. Ceci est extrêmement pratique pour le débogage, mais il est également nécessaire pour qu'il puisse être identifié de manière unique. Par exemple, trouvez un autre exactement le même modèle dans un autre même gamestate, ou indiquez dans la commande transmise au serveur un lien vers le modèle contenant la commande. Cela ressemble à ceci:

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

Et pourquoi, en fait, il est impossible d'avoir un objet enraciné dans un endroit, mais de s'y référer d'un autre? Et parce que vous imaginez que vous désérialisez un objet de JSON, et ici vous trouverez un lien vers un objet enraciné dans un endroit complètement différent. Et il n'y a toujours pas de place pour cela, il ne sera créé qu'à travers le plancher de désérialisation. Oups Veuillez ne pas proposer de désérialisation multipasse. C'est la limitation de cette méthode. Par conséquent, nous trouverons une deuxième méthode:

Tous les modèles créés par la deuxième méthode sont créés en un seul endroit magique, et dans tous les autres endroits du gamestate, seuls des liens y sont insérés. Lors de la désérialisation, s'il existe plusieurs références à l'objet, la première fois que vous accédez au lieu magique, l'objet est créé et avec toutes les références suivantes au même objet sont renvoyées. Pour implémenter d'autres fonctionnalités, nous supposons que le jeu peut avoir plusieurs gamestates, donc le lieu magique ne devrait pas être un commun, mais devrait être situé, par exemple, dans le gamestate. Pour les références à de tels modèles, nous utilisons une autre variante du descripteur PPersistent. Le modèle lui-même sera rendu plus spécial par Persistent: Model. Dans le code, cela ressemblera à ceci:

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

Un peu encombrant, mais il peut être utilisé. Pour poser des pailles, Persistent peut fixer le constructeur avec le paramètre ModelRoot, qui déclenchera une alarme s'il essaie de créer ce modèle sans utiliser les méthodes de ce ModelRoot.

J'ai les deux options dans mon code, et la question est, pourquoi alors utiliser la première option si la seconde couvre entièrement tous les cas possibles?

La réponse est que l'état du jeu doit être, tout d'abord, lisible par les gens. À quoi cela ressemble-t-il si, si possible, la première option est utilisée?

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

Et maintenant, à quoi ressemblerait-il si seule la deuxième option était utilisée:
 { "persistents":{ "1":{"money":10, "inventory":2}, "2":{"capacity":11} }, "player":1 } 

Pour déboguer personnellement, je préfère la première option.

Accéder aux propriétés du modèle


L'accès à des installations de stockage de propriétés réactives s'est finalement révélé caché sous le capot du modèle. Il n'est pas trop évident de savoir comment le faire fonctionner pour qu'il fonctionne rapidement, sans trop de code dans les modèles finaux et sans trop de réflexion. Examinons de plus près.

La première chose qu'il est utile de savoir sur Dictionary est que sa lecture ne prend pas autant de temps constant, quelle que soit la taille du dictionnaire. Nous allons créer un dictionnaire statique privé dans Model dans lequel chaque type de modèle se voit attribuer une description des champs qui s'y trouvent et nous y accéderons une fois lors de la construction du modèle. Dans le constructeur de type, nous cherchons à voir s'il existe une description pour notre type. Sinon, nous le créons, si c'est le cas, nous prenons le fini. Ainsi, la description ne sera créée qu'une seule fois pour chaque classe. Lors de la création d'une description, nous mettons dans chaque propriété statique (description de champ) les données extraites par réflexion - le nom du champ et l'index sous lequel le magasin de données pour ce champ sera dans le tableau. De cette façonlorsqu'il est accessible via la description du champ, son stockage sera retiré de la matrice à un index précédemment connu, c'est-à-dire rapidement.

Dans le code, cela ressemblera à ceci:

 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 } 

La conception est un peu simple, car les descripteurs de propriétés statiques déclarés dans les ancêtres de ce modèle peuvent déjà avoir des index de stockage enregistrés et l'ordre de renvoi des propriétés de Type.GetFields () n'est pas garanti. Pour l'ordre et pour que les propriétés ne soient pas réinitialisées en deux fois, vous devez vous surveiller.

Propriétés de collection


Dans la section sur l'arbre du modèle, on pouvait remarquer une construction qui n'était pas mentionnée précédemment: PDictionaryModel <int, Persistent> - un descripteur pour un champ contenant une collection. Il est clair que nous devrons créer notre propre référentiel pour les collections, qui stocke des informations sur l'apparence de la collection avant le début de la transaction et à quoi elle ressemble maintenant. Le caillou sous-marin a la taille d'un tonnerre sous Pierre I. Il consiste dans le fait que, ayant deux longs dictionnaires à portée de main, c'est une tâche extrêmement coûteuse de calculer le diff entre eux. Je suppose que ces modèles devraient être utilisés pour toutes les tâches liées aux méta, ce qui signifie qu'ils devraient fonctionner rapidement. Au lieu de stocker deux états, de les cloner, puis de les comparer, je fais un crochet délicat - seul l'état actuel du dictionnaire est stocké dans le magasin. Deux autres dictionnaires sont des valeurs supprimées,et les anciennes valeurs des éléments remplacés. Enfin, un ensemble de nouvelles clés ajoutées au dictionnaire est stocké. Ces informations se remplissent facilement et rapidement. Il est facile de générer toutes les différences nécessaires avec elle, et il suffit de restaurer l'état précédent si nécessaire. En code, cela ressemble à ceci:

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

Je n'ai pas réussi à créer un référentiel tout aussi beau pour la Liste, ou je n'ai pas assez de temps, j'en garde deux exemplaires. Un module complémentaire supplémentaire est nécessaire pour essayer de minimiser la taille du diff.

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

Total


Si vous savez clairement ce que vous voulez recevoir et comment, vous pouvez écrire tout cela en quelques semaines. La vitesse de développement du jeu change en même temps si radicalement que lorsque je l'ai essayé, je n'ai même pas commencé mes propres jeux de création de jeu sans avoir un bon moteur. Tout simplement parce que le premier mois, l'investissement pour moi a évidemment porté ses fruits. Bien sûr, cela ne s'applique qu'aux méta. Le gameplay doit être fait à l'ancienne.

Dans la prochaine partie de l'article, je parlerai des commandes, de la mise en réseau et de la prédiction des réponses du serveur. Et j'ai aussi quelques questions pour vous qui sont très importantes pour moi. Si vos réponses diffèrent de celles données entre parenthèses, je me ferai un plaisir de les lire dans les commentaires ou peut-être même d'écrire un article. Merci d'avance pour les réponses.

PS Une proposition de coopération et des instructions sur de nombreuses erreurs de syntaxe, veuillez en PM.

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


All Articles