Epigraph:
- How do I appreciate it if you do not know what to do?
- Well, there will be small screens and buttons.
- Dima, you have now described my whole life in three words!
(c) Real dialogue at the rally in the gaming company
The set of needs and the solutions that meet them, which I will talk about in this article, was formed during my participation in about a dozen major projects, first on Flash, and later on Unity. The largest of the projects had more than 200000 DAU and added new original challenges to my piggy bank. On the other hand, the relevance and necessity of previous finds was confirmed.
')
In our harsh reality, everyone who has ever architectured a large project at least in his thoughts has his own ideas about how to do it, and is often ready to defend his ideas to the last drop of blood. For others, this causes a smile, and the management often looks at all this as a huge black box that no one has rested against. But what if I tell you that the right solutions will help reduce the creation of a new functionality by 2-3 times, find errors in the old 5-10 times, and allow you to do many new and important things that were previously not available at all? It is enough just to let architecture into your heart!
Architectural solutions for mobile games. Part 2: Command and their queuesArchitectural solutions for mobile games. Part 3: View on jet propulsionModel
Field access
Most programmers recognize the importance of using something like MVC. Few people use pure MVC from the book of the gang of four, but all solutions from normal offices are somehow similar to this pattern in spirit. Today we will talk about the first of the letters in this abbreviation. Because a large part of the work of programmers in a mobile game is a new feature in a metagame, implemented as model manipulations, and hooking thousands of interfaces to these features. And the convenience of the model plays a key role in this lesson.
I don’t cite the full code, because it's a bit dofig, and in general it's not about him. I will illustrate my reasoning with the simplest example:
public class PlayerModel { public int money; public InventoryModel inventory; public void SomeTestChanges() { money = 10; inventory.capacity++; } }
This option doesn’t suit us at all, because the model does not send events about the changes occurring in it. If the information about which fields were affected by the changes and which are not, and which need to be redrawn, and which are not, the programmer will indicate manually in one form or another - that is what will become the main source of errors and time-consuming. And just do not have to do surprised eyes. In most of the large offices in which I worked, the programmer himself sent all sorts of InventoryUpdatedEvent, and in some cases also manually filled them. Some of these offices earned millions, do you think, thanks to or contrary to?
We will use some of our ReactiveProperty <T> class, which will hide under the hood all the manipulations on sending messages that we need. It turns out like this:
public class PlayerModel : Model { public ReactiveProperty<int> money = new ReactiveProperty<int>(); public ReactiveProperty<InventoryModel> inventory = new ReactiveProperty<InventoryModel>(); public void SomeTestChanges() { money.Value = 10; inventory.Value.capacity.Value++; } public void Subscription(Text text) { money.SubscribeWithState(text, (x, t) => t.text = x.ToString()); } }
This is the first version of the model. This option is already a dream for many programmers, but I still don’t like it. The first thing I don’t like is that addressing values ​​is complicated. I managed to get confused while writing this example, having forgotten in one place Value. And it’s these data manipulations that make up the lion’s share of everything that is done and confused with the model. If you are using the 4.x version of the language, you can do this:
public ReactiveProperty<int> money { get; private set; } = new ReactiveProperty<int>();
but this does not solve all problems. I would like to write simply: inventory.capacity ++; Suppose we try to do get for each field of the model; set; But in order to subscribe to events, we also need access to the ReactiveProperty itself. A clear inconvenience and source for confusion. With that, we only need to specify which field we are going to follow. And here I came up with a cunning maneuver that I liked.
See if you like it.
It is not the ReactiveProperty itself that is inserted into the specific model that the programmer writes the rules, but his static PValue descriptor, the heir to the more general Property, identifies the field, and inside it, the creation and storage of the desired type of ReactiveProperty is hidden. Not the most successful name, but it happened, then rename it.
In code, it looks like this:
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) } } public void SomeTestChanges() { money = 10; inventory.capacity++; } public void Subscription(Text text) { this.Get(MONEY).SubscribeWithState(text, (x, t) => t.text = x.ToString()); } }
This is the second option. The common ancestor of the Model, of course, was complicated by the creation and acquisition of a real ReactiveProperty from its descriptor, but this can be done very quickly and without reflection, or rather, using reflection only once during the class initialization stage. And this is work that is done once by the creator of the engine, and then it will be used by everyone. In addition, this design allows you to avoid accidental attempts to manipulate the ReactiveProperty itself instead of the values ​​stored in it. The creation of the field itself is cluttered up, but in all cases it is exactly the same, and it can be created with a template.
At the end of the article there is a poll which option you like more.
Everything that is described further can be implemented in both versions.
Transactions
I want programmers to be able to change the model fields only when it is allowed by the restrictions adopted in the engine, that is, within the command, and never again. To do this, the setter must go somewhere and check whether the transaction-command is currently open, and only then allow it to edit the information in the model. This is very necessary, because the users of the engine regularly try to do something strange bypassing the typical process, breaking the logic of the engine and causing subtle errors. I saw this more than once or twice.
There is a belief that if you make a separate interface for reading data from the model and for writing, it will somehow help. In reality, the model is overgrown with additional files and tedious additional operations. These restrictions are finite. Programmers are forced, firstly, to know and constantly think about them: “what should each particular function, model or its interface give,” and secondly, situations also arise when these restrictions must be circumvented, so that at the exit, we have d'Artagnan, who invented it all in white, and a lot of users of his engine, who are bad guardsmen of the Project Manager, and, despite the constant abuse, nothing works as expected. Therefore, I prefer to just tightly block the possibility of making such a mistake. Reduce the dose of conventions, so to speak.
The ReactiveProperty setter should have a link to the place where the current state of the transaction is checked. Suppose this place is classModelRoot. The simplest option is to pass it to the model constructor explicitly. The second version of the code when calling RProperty receives a link to this explicitly, and can get all the necessary information from there. For the first variant of the code, you will have to reflex all fields of type ReactiveProperty in the constructor and distribute a reference to this for further manipulations. A slight inconvenience is the need to create in each model an explicit constructor with a parameter, something like this:
public class PlayerModel : Model { public PlayerModel(ModelRoot gamestate) : base (gamestate) {} }
But for other features of the models, it is very useful for the model to have a reference to the parent model, forming a doubly connected construction. In our example, this will be player.inventory.Parent == player. And then this constructor can be avoided. Any model will be able to receive and cache a link to a magical place from its parent, and that one from its parent, and so on until the next parent turns out to be that very magical place. As a result, at the level of declarations, all this will look like this:
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 this beauty will be filled automatically when the model is hit in the gamestate tree. Yes, the newly created model, which has not yet got there, will not be able to learn about the transaction and block manipulations with itself, but if this is not allowed by the state of the transaction, she will not be able to go to the state after that, the future parent’s setter will not allow it, so that the inviolability of the gamestat will not be affected. Yes, it will require additional work at the programming stage of the engine, but the programmer using the engine will completely eliminate the need to know and think about it until he tries to do something wrong and doesn’t catch it.
Since the conversation about transactness has already started, messages about changes should not be processed immediately after the change is made, but only when all the manipulations with the model within the current team are completed. There are two reasons for this, the first is data consistency. Not all data states are internally consistent. Perhaps you should not try to draw it. Or if you were impatient, for example, sort an array or change some model variable in a loop. You should not receive hundreds of change messages.
This can be done in two ways. The first is to subscribe to updates of a variable, use a clever function that adds a stream of transaction endings to the variable change flow, and the messages will only be skipped after them. This is fairly easy to do if you are using UniRX, for example. But this option has many flaws, in particular, it generates a lot of unnecessary moving things. Personally, I like the other option.
Each ReactiveProperty will remember its state before the start of the transaction and its current state. A message about the change and fixation of the changes will be made only at the end of the transaction. In the case when the object of the change was some collection, this will allow to include information about the changes in the sent message explicitly. For example, such two elements in the list were added, and these two were removed. Instead of just saying that something has changed, and forcing the recipient to analyze the list itself with a length of a thousand elements in search of information that needs to be redrawn.
public partial class Model { public void DispatchChanges(Command transaction); public void FixChanges(); public void RevertChanges(); }
The option is more time consuming at the stage of creating the engine, but then it costs less to use. And most importantly, it opens up the possibility for the next improvement.
Information about changes made to the model
I want more from the model. At any moment I want to easily and conveniently see what has changed in the state of the model as a result of my actions. For example, in this form:
{"player":{"money":10, "inventory":{"capacity":11}}}
Most often, it is useful for the programmer to see the diff between the state of the model before and after the start of the command, or at some point within the command. Some for this clone the entire gamestate before the start of the team, and then compare. This partially solves the problem at the debugging stage, but it is absolutely impossible to launch this in the sale. What is cloning a state, what is the calculation of a slight difference between two lists is a monstrously expensive operation to do with any sneeze.
Therefore, ReactiveProperty must keep not only its current state, but also the previous one. This gives rise to a whole group of extremely useful features. Firstly, the extraction of the difference in such a situation happens quickly, and we can calmly dump it all in the prod. Secondly, you can get not a cumbersome diff, but a compact hash from changes, and compare it with a hash of changes in another one of the same game stack. If you do not agree - you have a problem. Thirdly, if the execution of the command fell with an exept, you can always undo the changes and find out about the unspoiled state at the time of the start of the transaction. Together with the command applied to the state, this information is invaluable, because you can easily reproduce the situation exactly. Of course, for this you need to have ready-made functionality for convenient serialization and deserialization of the gamestat, but in any case you will need it.
Serialization of changes made in the model
The engine provides serialization and binary, and in json - and this is not accidental. Of course, binary serialization takes up much less space and runs much faster, which is important, especially during the initial load. But this is not a human-readable format, and here we are praying for debugging convenience. In addition, there is another underwater rock. When your game goes to the prod, you will need to constantly switch from version to version. If your programmers follow some simple precautions and do not erase anything unnecessarily from the gamestate, you will not feel this transition. But for obvious reasons, in binary format string field names, you will have to read the binary of the old version of the state, export to something more informative, for example, in the same json, and then import it into the new, write, and only after all this work on as usual. As a result, in some projects, configs are written into binaries because of their cyclopean sizes, and the state already prefers to drag it back and forth in the form of json. Estimate the overhead and choose you.
[Flags] public enum ExportMode { all = 0x0, changes = 0x1, serverVerified = 0x2,
The signature of the Export method (ExportMode mode, out Dictionary <string, object> data) is somewhat alarming. And the thing is this: When you serialize an entire tree, you can write directly to the stream, or in our case, JSONWriter, which is a simple superstructure over StringWriter. But when you export changes is not so simple, because when you go deep into a tree, go into one of the branches and you still don’t know if you need to export anything from it at all. Therefore, at this stage I came up with two solutions, one simpler, the second more difficult and more economical. The simpler thing is that by exporting only changes you turn all changes into a tree from Dictionary <string, object> and List <object>. And then what happened was feeding your favorite serializer. This is a simple approach that does not require dancing with a tambourine. But its disadvantage is that in the process of exporting changes in the heap there will be allocated a place for disposable collections. In fact, there is not so much space, because this full export gives a big tree, and a typical team leaves very little change in the tree.
However, many people believe that feeding the Garbage Collector as a troll is not necessary unless absolutely necessary. For them, and to calm my conscience, I prepared a more complex solution:
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); }
The essence of this method is to pass through the tree twice. For the first time, view all models that have changed themselves, or have changes in child models, and write them all in the Queue <Model> ierarchyChanges exactly in the order in which they are found in the tree in its current state. Not a lot of changes, the queue will not be long. In addition, nothing prevents to keep Stack <Model> and Queue <Model> between calls and then there will be very few allocations in the call process.
And already passing the second time through the tree, it will be possible to look at the top of the queue each time, and to understand whether you need to go into a given branch of the tree or immediately go on. This allows JSONWriter to write immediately without returning any other intermediate results.
It is very likely that this complication is not really necessary, because later you will see that you only need to export changes in the tree for debugging or when falling from Exception. During normal operation, everything is limited to GetHashCode (ExportMode mode, out int code) to which all these delights are deeply alien.
Before we continue to complicate our model, let's talk about this.
Why is it so important
All programmers say that this is terribly important, but nobody usually believes them. Why?
Firstly, because all programmers say that you need to throw out the old and write a new one. Absolutely everything, regardless of qualifications. There is no managerial way to find out if this is true or not, and experiments, as a rule, cost too much. The manager will have to choose one programmer and trust his judgment. The problem is that such an adviser is, as a rule, the one with whom the management has been working for a long time and evaluates him on whether he was able to realize his ideas. And all his best ideas have already been translated into reality. So this, too, is not at all an ideal way to find out how good others are and not similar ideas.
Secondly, 80% of all mobile games bring in their entire life less than $ 500. Therefore, at the beginning of the project, management has other problems, more important than the architecture. But the decisions taken at the very beginning of the project take people hostage and do not release from six months to three years. The process of refactoring and switching to other ideas in an already working project, which also has customers, is a very difficult, expensive and risky business. If for the project, at the very beginning, investing three person-months in a normal architecture seems an unaffordable luxury, then what can you say about the cost of postponing an update with new features for a couple of months?
Thirdly, even if the idea “how it should be” is in itself a good one and the perfect one does not know how long its implementation will take. The dependence of the time spent on the programmer's coolness is very nonlinear. The simple task of the senier will not make much faster than the junior. One and a half times, perhaps. But each programmer has his own “limit of complexity”, beyond which his effectiveness dramatically decreases. I had a case in my life when I needed to implement a rather complex architectural task, and even a full concentration on the task of turning off the Internet in the house and ordering ready-made food for a month did not help. But two years later, after reading interesting books and pecking on related tasks I solved this problem in three days. I am sure everyone will remember something in their careers. And here lies the trick! The fact is that if a brilliant idea came to you in your head, as it should be, then, most likely, this new idea is somewhere at your personal limit of complexity, and maybe even a little bit behind it. Management, having repeatedly burned himself on such, begins to blow on any new ideas. And if you make a game for yourself, the result may be even worse, because there will be no one to stop you.
But how, with all this, does anyone even manage to use good solutions? There are several ways.
Firstly, each company wants to hire a ready-made person who has already done this with the previous employer. This is the most common way to shift the burden of experimentation to someone else.
Secondly, companies or people who have made their first successful game, swallowed, and starting the next project are ready for the changes.
-, , - , . , .
-, , , , , , - .
, : . , . : , 1 1000, QA , 200 . , , , ? , , 10 .
Model
. - . – , . , ModelRoot. , , . , ModelProperty . :
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); } } }
? Parent , , , Parent null. , . – , , . , , :
- PValue , , , , , . , ,runtime , , .
- PModel Parent - , . . , .
, , , – . .
, – , ModelRoot . , , . , , , . :
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); }
, , , ? , JSON-, , . , . . . . :
, , , . , . , , , , , . PPersistent. Persistent: Model. :
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 };
, . , Persistent ModelRoot, , ModelRoot.
, , , ?
, , , . , , ?
{ "persistents":{}, "player":{ "money":10, "inventory":{"capacity":11} } }
:
{ "persistents":{ "1":{"money":10, "inventory":2}, "2":{"capacity":11} }, "player":1 }
.
. , , , . .
Dictionary — , . Model , , . , . , , – . , . Property ( ) , – , , . , , .
:
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 }
- , , , , Type.GetFields() . , , .
, : PDictionaryModel<int, Persistent> – , . , , , . - I. , , , diff . , , , , . , , , , – . – , . , Set . . diff-, , , , . :
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>(); }
List- , , . , diff-.
public class ListStorage<TValue> : BaseStorage { public List<TValue> current = new List<TValue>(); public List<TValue> previouse = new List<TValue>();
Total
, , . , , , . , . , . .
, . , . . Thanks in advance for the answers.
PS .