Soluciones arquitectónicas para un juego móvil. Parte 1: modelo

Epígrafe:
- ¿Cómo evaluaré si no sabes qué hacer?
- Bueno, habrá pantallas y botones.
- Dima, ¡ahora has descrito toda mi vida en tres palabras!
(c) Diálogo real en un mitin en una empresa de juegos



El conjunto de necesidades y las soluciones que las satisfacen, que analizaré en este artículo, se formaron durante mi participación en aproximadamente una docena de grandes proyectos, primero en Flash y luego en Unity. El mayor de los proyectos tenía más de 200,000 DAU y complementó mi hucha con nuevos desafíos originales. Por otro lado, se confirmó la relevancia y la necesidad de hallazgos anteriores.

En nuestra dura realidad, todos los que han diseñado al menos una vez un gran proyecto, al menos en sus pensamientos, tienen sus propias ideas sobre cómo hacerlo, y a menudo están listos para defender sus ideas hasta la última gota de sangre. Para otros, me hace sonreír, y la gerencia a menudo ve todo esto como una gran caja negra que no ha descansado contra nadie. Pero, ¿qué sucede si le digo que las soluciones correctas ayudarán a reducir la creación de nuevas funcionalidades en 2-3 veces, la búsqueda de errores en las antiguas 5-10 veces y le permitirán hacer muchas cosas nuevas e importantes que antes no estaban disponibles? ¡Es suficiente dejar que la arquitectura entre en tu corazón!
Soluciones arquitectónicas para un juego móvil. Parte 2: Comando y sus colas
Soluciones arquitectónicas para un juego móvil. Parte 3: Ver en el empuje del jet


Modelo


Acceso a los campos


La mayoría de los programadores reconocen la importancia de usar algo como MVC. Pocas personas usan MVC puro del libro de una pandilla de cuatro, pero todas las decisiones de las oficinas normales son de alguna manera similares a este patrón en espíritu. Hoy hablaremos sobre la primera de las letras en esta abreviatura. Debido a que una gran parte del trabajo de los programadores en un juego móvil son nuevas características en el metajuego, implementadas como manipulaciones con el modelo, y atornillando miles de interfaces en estas características. Y la conveniencia del modelo juega un papel clave en esta lección.

No proporciono el código completo, porque es un poco tonto, y en general no se trata de él. Ilustraré mi razonamiento con un ejemplo simple:

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

Esta opción no nos conviene en absoluto, porque el modelo no envía eventos sobre los cambios que ocurren en él. Si la información sobre qué campos se vieron afectados por los cambios, y cuáles no, y cuáles necesitan ser redibujados y cuáles no, el programador lo indicará manualmente de una forma u otra, esta se convertirá en la principal fuente de errores y tiempo invertido. Y simplemente no tengo que mirar con sorpresa: en la mayoría de las grandes oficinas en las que trabajé, el programador envió todo tipo de InventoryUpdatedEvent y, en algunos casos, también los llenó manualmente. Algunas de estas oficinas han hecho millones, ¿crees que, gracias o a pesar de eso?

Usaremos nuestra propia clase ReactiveProperty <T> que ocultará debajo del capó todas las manipulaciones para enviar mensajes que necesitamos. Se verá más o menos así:

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

Esta es la primera versión del modelo. Esta opción ya es un sueño para muchos programadores, pero todavía no me gusta. Lo primero que no me gusta es que acceder a los valores es complicado. Me las arreglé para confundirme mientras escribía este ejemplo, olvidando Value en un lugar, y son precisamente estas manipulaciones de datos las que constituyen la mayor parte de todo lo que se hace y se confunde con el modelo. Si está utilizando la versión de idioma 4.x, puede hacer esto:

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

Pero esto no resuelve todos los problemas. Me gustaría escribir simplemente: Inventory.capacity ++;. Supongamos que intentamos obtener para cada campo modelo; conjunto Pero para suscribirse a eventos, también necesitamos acceso a ReactiveProperty. Claro inconveniente y fuente de confusión. A pesar de que solo necesitamos indicar qué campo vamos a monitorear. Y aquí se me ocurrió una maniobra complicada que me gustó.

A ver si te gusta.

No se inserta ReactiveProperty en el modelo concreto con el que está tratando el programador, se inserta, pero su descriptor estático PValue, el heredero de la propiedad más general, identifica el campo, y dentro del capó del constructor del modelo se oculta la creación y el almacenamiento de la ReactiveProperty del tipo deseado. No es el mejor nombre, pero sucedió, luego cambió de nombre.

En código, se ve así:

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

Esta es la segunda opción. El antepasado general del Modelo, por supuesto, fue complicado a expensas de crear y extraer una Propiedad Reactiva real de acuerdo con su descriptor, pero esto se puede hacer muy rápidamente y sin reflexión, o más bien, aplicando la reflexión solo una vez en la etapa de inicialización de la clase. Y este es el trabajo que realiza una vez el creador del motor, y luego será utilizado por todos. Además, este diseño evita intentos accidentales de manipular ReactiveProperty en lugar de los valores almacenados en él. La creación del campo está abarrotada, pero en todos los casos es exactamente la misma y se puede crear con una plantilla.

Al final del artículo hay una encuesta que opción le gusta más.
Todo lo que se describe a continuación se puede implementar en ambas versiones.

Transacciones


Quiero que los programadores puedan cambiar los campos del modelo solo cuando las restricciones adoptadas en el motor lo permitan, es decir, dentro del equipo, y nunca más. Para hacer esto, el configurador debe ir a algún lado y verificar si el comando de transacción está actualmente abierto, y solo entonces permitir que la información se edite en el modelo. Esto es muy necesario, porque los usuarios del motor regularmente intentan hacer algo extraño para evitar un proceso típico, rompiendo la lógica del motor y causando errores sutiles. Vi esto más de una o dos veces.

Existe la creencia de que si crea una interfaz separada para leer datos del modelo y para escribir, de alguna manera será útil. En realidad, el modelo está cubierto de archivos adicionales y tediosas operaciones adicionales. Estas restricciones son finales. Los programadores se ven obligados, en primer lugar, a conocerlos y pensar constantemente sobre ellos: "lo que cada función específica, modelo o interfaz debe dar", y en segundo lugar, las situaciones también surgen cuando estas restricciones deben ser sorteadas, a la salida tenemos a D'Artagnan, quien se le ocurrió todo esto en blanco, y muchos usuarios de su motor, que son malos guardianes del Project Manager, y a pesar de los constantes abusos, nada funciona según lo previsto. Por lo tanto, prefiero bloquear estrictamente la posibilidad de tal error. Reduzca la dosis de convenciones, por así decirlo.

El configurador ReactiveProperty debe tener un enlace al lugar donde se debe verificar el estado actual de la transacción. Digamos que este lugar es classCModelRoot. La opción más fácil es pasarlo explícitamente al constructor del modelo. La segunda versión del código al llamar a RProperty recibe un enlace a esto explícitamente, y puede obtener toda la información necesaria desde allí. Para la primera versión del código, tendrá que correr alrededor de los campos del tipo ReactiveProperty en el constructor con una reflexión y darles un enlace a este para futuras manipulaciones. Un pequeño inconveniente es la necesidad de crear un constructor explícito con un parámetro en cada modelo, algo como esto:

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

Pero para otras características de los modelos, es muy útil que el modelo tenga un enlace con el modelo padre, formando una construcción biconécta. En nuestro ejemplo, este será player.inventory.Parent == player. Y entonces este constructor puede ser evitado. Cualquier modelo podrá obtener y almacenar en caché un enlace a un lugar mágico de su padre, y ese de su padre, y así sucesivamente hasta que el próximo padre resulte ser ese lugar mágico. Como resultado, a nivel de declaraciones, todo esto se verá así:

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

Toda esta belleza se llenará automáticamente cuando la modelo entre al árbol del estado del juego. Sí, el modelo recién creado, que aún no ha llegado allí, no podrá aprender sobre la transacción y bloquear las manipulaciones consigo mismo, pero si el estado de la transacción está prohibido, no podrá ingresar al estado después de eso, el configurador del futuro padre no lo permitirá, por lo que la integridad del estado del juego no se verá afectada. Sí, esto requerirá un trabajo adicional en la etapa de programación del motor, pero por otro lado, un programador que usa el motor eliminará por completo la necesidad de saberlo y pensarlo hasta que intente hacer algo mal y lo ponga en sus manos.

Dado que la conversación sobre la actividad ha comenzado, los mensajes sobre los cambios no deben procesarse inmediatamente después de realizar el cambio, sino solo cuando se completan todas las manipulaciones con el modelo dentro del comando actual. Hay dos razones para esto, la primera es la coherencia de los datos. No todos los estados de datos son coherentes internamente. Quizás no pueda intentar renderizarlos. O si está impaciente, por ejemplo, para ordenar una matriz o cambiar alguna variable de modelo en un bucle. No debe recibir cientos de mensajes de cambio.

Hay dos formas de hacer esto. El primero es suscribirse a las actualizaciones de una variable y usar una función complicada que agrega una secuencia de terminaciones de transacciones a la secuencia de cambios en la variable y solo después saltará los mensajes. Esto es bastante fácil de hacer si está utilizando UniRX, por ejemplo. Pero esta opción tiene muchas deficiencias, en particular genera muchos movimientos innecesarios. Personalmente, me gusta la otra opción.

Cada propiedad reactiva recordará su estado antes del inicio de la transacción y su estado actual. Solo se enviará un mensaje sobre el cambio y la fijación de los cambios al final de la transacción. En el caso de que el objeto del cambio fuera algún tipo de recopilación, esto permitirá incluir explícitamente información sobre los cambios que ocurrieron en el mensaje enviado, por ejemplo, se agregaron esos dos elementos en la lista y se eliminaron. En lugar de decir simplemente que algo ha cambiado y obligar al destinatario a analizar una lista de mil elementos de longitud en busca de información que necesita ser redibujada.

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

La opción lleva más tiempo en la etapa de creación del motor, pero el costo de uso es más bajo. Y lo más importante, abre la posibilidad de la próxima mejora.

Información sobre los cambios realizados en el modelo.


Quiero más del modelo. En cualquier momento quiero ver fácil y convenientemente qué ha cambiado en el estado del modelo como resultado de mis acciones. Por ejemplo, de esta forma:

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

Muy a menudo, es útil para el programador ver la diferencia entre el estado del modelo antes del inicio del comando y después de su finalización, o en algún punto dentro del comando. Algunos para este clon de todo el estado del juego antes del inicio del equipo, y luego comparar. Esto resuelve parcialmente el problema en la etapa de depuración, pero es absolutamente imposible ejecutar esto en el producto. Esa clonación estatal, que calcular la diferencia insignificante entre las dos listas, es una operación monstruosamente costosa de hacer con cualquier estornudo.

Por lo tanto, ReactiveProperty debe almacenar no solo su estado actual, sino también el anterior. Esto da lugar a todo un grupo de oportunidades extremadamente útiles. En primer lugar, la extracción de la diferencia en tal situación es rápida, y podemos volcarlo tranquilamente en la comida. En segundo lugar, no puede obtener un diferencial voluminoso, sino un pequeño hash compacto de los cambios, y compararlo con un hash de cambios en otro mismo estado de juego. Si no está de acuerdo, tienes problemas. En tercer lugar, si la ejecución del comando cayó con la ejecución, siempre puede cancelar los cambios y averiguar sobre el estado intacto en el momento en que comenzó la transacción. Junto con el equipo aplicado al estado, esta información es invaluable porque puede reproducir fácilmente la situación con precisión. Por supuesto, para esto necesitas tener una funcionalidad preparada para una serialización y deserialización conveniente del estado del juego, pero la necesitarás de todos modos.

Serialización de cambios de modelo


El motor proporciona serialización y binario, y en json, y esto no es accidental. Por supuesto, la serialización binaria ocupa mucho menos espacio y funciona mucho más rápido, lo cual es importante, especialmente durante el arranque inicial. Pero este no es un formato legible por humanos, y aquí oramos por la conveniencia de la depuración. Además, hay otra trampa. Cuando su juego vaya a producción, deberá cambiar constantemente de una versión a otra. Si sus programadores siguen algunas precauciones simples y no eliminan nada del estado del juego innecesariamente, no sentirán esta transición. Y en el formato binario, no hay nombres de cadena de campo por razones obvias, y si las versiones no coinciden, tendrá que leer el binario con la versión anterior del estado, exportarlo a algo más informativo, por ejemplo, el mismo json, luego importarlo a un nuevo estado, exportarlo al binario, anote, y solo después de todo este trabajo, como de costumbre. Como resultado, en algunos proyectos, las configuraciones se escriben en los archivos binarios en vista de sus tamaños ciclópeos, y ya prefieren arrastrar el estado hacia adelante y hacia atrás en forma de json. Evaluar gastos generales y elegirlo.

 [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 firma del método Exportar (modo ExportMode, fuera de los datos del Diccionario <cadena, objeto>) es algo alarmante. Y la cuestión es esta: cuando serializa todo el árbol, puede escribir directamente en la secuencia, o en nuestro caso, en JSONWriter, que es un complemento simple para StringWriter. Pero cuando exporta cambios, no es tan simple, porque cuando se adentra en un árbol y se adentra en una de las ramas, todavía no sabe si exportar algo de él. Por lo tanto, en esta etapa se me ocurrieron dos soluciones, una más simple, la segunda más complicada y económica. Una más simple es que al exportar solo cambios, convierte todos los cambios en un árbol desde Diccionario <cadena, objeto> y Lista <objeto>. Y luego, qué pasó, alimente a su serializador favorito. Este es un enfoque simple que no requiere bailar con una pandereta. Pero su inconveniente es que en el proceso de exportación de cambios al montón, se asignará un lugar para las colecciones únicas. De hecho, no hay mucho espacio, porque esta exportación completa da un gran árbol, y el comando típico deja muy pocos cambios en el árbol.

Sin embargo, muchas personas creen que alimentar al recolector de basura como ese troll no es necesario sin una necesidad extrema. Para ellos, y para calmar mi conciencia, preparé una solución más compleja:

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

La esencia de este método es caminar dos veces por el árbol. Por primera vez, vea todos los modelos que se han modificado a sí mismos, o que tienen cambios en los modelos secundarios, y escríbalos todos en Cola <Modelo> ierarchyChanges exactamente en el orden en que aparecen en el árbol en su estado actual. No hay muchos cambios, la cola no será larga. Además, nada impide mantener Stack <Model> y Queue <Model> entre llamadas y luego habrá muy pocas asignaciones durante la llamada.

Y ya pasando la segunda vez a través del árbol, será posible mirar la parte superior de la cola cada vez y comprender si es necesario entrar en esta rama del árbol o seguir de inmediato. Esto permite que JSONWriter escriba inmediatamente sin devolver ningún otro resultado intermedio.

Es muy probable que esta complicación no sea realmente necesaria, porque más adelante verá que exportar los cambios al árbol que necesita solo para la depuración o cuando se bloquea con Exception. Durante el funcionamiento normal, todo se limita a GetHashCode (modo ExportMode, out int code) al que todas estas delicias son profundamente ajenas.

Antes de continuar complicando nuestro modelo, hablemos de esto.

¿Por qué es tan importante?


Todos los programadores dicen que esto es terriblemente importante, pero generalmente nadie les cree. Por qué

En primer lugar, porque todos los programadores dicen que debes tirar lo viejo y escribir lo nuevo. Eso es todo, independientemente de las calificaciones. No existe una forma administrativa de averiguar si esto es cierto o no, y los experimentos suelen ser demasiado caros. El gerente se verá obligado a elegir un programador y confiar en su juicio. El problema es que ese asesor suele ser aquel con quien la gerencia ha estado trabajando durante mucho tiempo y lo evalúa en función de si fue capaz de realizar sus ideas, y todas sus mejores ideas ya están plasmadas en la realidad. Por lo tanto, esta tampoco es una forma ideal de descubrir qué tan buenas son las ideas ajenas y diferentes.

En segundo lugar, el 80% de todos los juegos móviles traen menos de $ 500 en toda su vida. Por lo tanto, al comienzo del proyecto, la administración tiene otros problemas, más importante la arquitectura. Pero las decisiones tomadas al comienzo del proyecto toman a la gente como rehenes y no la dejan pasar de seis meses a tres años. El proceso de refactorización y cambio a otras ideas en un proyecto que ya funciona, que también tiene clientes, es un negocio muy difícil, costoso y arriesgado. Si para un proyecto desde el principio, invertir tres meses-hombre en una arquitectura normal parece un lujo inadmisible, entonces, ¿qué puede decir sobre el costo de retrasar la actualización con nuevas características durante un par de meses?

En tercer lugar, incluso si la idea de "cómo debería ser" en sí misma es buena e ideal, no se sabe cuánto tiempo llevará su implementación. La dependencia del tiempo dedicado a la frescura del programador es muy no lineal. El señor hará una tarea simple no mucho más rápida que la junior. Una vez y media, tal vez. Pero cada programador tiene su propio "límite de complejidad", más allá del cual su efectividad cae dramáticamente. Tuve un caso en mi vida en el que necesitaba realizar una tarea arquitectónica bastante complicada, e incluso concentrarme completamente en el problema de apagar Internet en la casa y pedir comidas preparadas por un mes no ayudó, pero dos años después, después de leer libros interesantes y resolver tareas relacionadas Resolví este problema en tres días. Estoy seguro de que todos recordarán algo así en su carrera. Y aquí está el truco! El hecho es que si se le ocurrió una idea ingeniosa como debería ser, entonces lo más probable es que esta nueva idea esté en algún lugar de su límite personal de complejidad, y tal vez incluso un poco detrás. La gerencia, que se ha quemado en repetidas ocasiones sobre eso, comienza a soplar cualquier idea nueva. Y si haces el juego por ti mismo, el resultado puede ser aún peor, porque no habrá nadie que te detenga.

Pero, ¿cómo, entonces, alguien logra usar buenas soluciones? Hay varias formas

En primer lugar, cada compañía quiere contratar a una persona preparada que ya haya hecho esto con un empleador anterior. Esta es la forma más común de transferir la carga de la experimentación a otra persona.

En segundo lugar, las empresas o personas que hicieron su primer juego exitoso, sorbieron y comenzaron el próximo proyecto están listos para los cambios.

En tercer lugar, honestamente admítete a ti mismo que a veces haces algo no por el salario, sino por el placer del proceso. Lo principal es encontrar tiempo para esto.

Cuarto, es un conjunto de soluciones y bibliotecas comprobadas, junto con personas, que constituyen los fondos principales de la compañía de juegos, y esto es lo único que permanecerá en él cuando alguna persona clave se vaya y se mude a Australia.

La última, aunque no la razón más obvia: porque es terriblemente beneficiosa. Las buenas soluciones conducen a una reducción múltiple en el tiempo para escribir nuevas funciones, depurarlas y detectar errores. Permítanme darles un ejemplo: hace dos días, el cliente tuvo una ejecución en una nueva característica, cuya probabilidad es 1 de 1000, es decir, el control de calidad será torturado para reproducirse, y cuando lo da, son 200 mensajes de error por día. ¿Cuánto tiempo le llevará reproducir la situación y atrapar al cliente en el punto de interrupción una línea antes de que todo colapse? Por ejemplo, tengo 10 minutos.

Modelo


Árbol modelo


El modelo consta de muchos objetos. Los diferentes programadores deciden de manera diferente cómo conectarlos. La primera forma es cuando el modelo se identifica por el lugar donde se encuentra. Esto es muy conveniente y simple cuando la referencia al modelo pertenece a un solo lugar en ModelRoot. Quizás incluso se pueda cambiar de un lugar a otro, pero dos enlaces de diferentes lugares nunca conducen a él. Haremos esto introduciendo una nueva versión del descriptor ModelProperty que tratará los enlaces de un modelo a otros modelos ubicados en él. En el código, se verá así:

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

Cual es la diferencia Cuando se agrega un nuevo modelo a este campo, el modelo en el que se agregó se escribe en su campo Principal, y cuando se elimina, el campo Principal se restablece. En teoría, todo está bien, pero hay muchas dificultades. El primero, los programadores que lo usarán, pueden estar equivocados. Para evitar esto, imponemos controles ocultos en este proceso, desde diferentes ángulos:

  1. Arreglaremos PValue para que verifique el tipo de su valor y los expertos lo juren cuando intenten almacenar una referencia al modelo en él, lo que indica que para esto es necesario usar una construcción diferente, solo para no confundirse. Esto, por supuesto, es una verificación de tiempo de ejecución, pero jura al primer intento de inicio, por lo que lo hará.
  2. PModel Parent - , . . , .

Un efecto secundario surge de esto, si necesita cambiar dicho modelo de un lugar a otro, primero debe eliminarlo del primer lugar y solo luego agregarlo al segundo; de lo contrario, los cheques lo regañarán. Pero esto realmente sucede muy raramente.

Dado que el modelo se encuentra en un lugar estrictamente definido y tiene una referencia a su padre, podemos agregarle un nuevo método: puede decir en qué dirección se encuentra en el árbol ModelRoot. Esto es extremadamente conveniente para la depuración, pero también es necesario para que pueda identificarse de manera única. Por ejemplo, busque otro exactamente el mismo modelo en otro mismo estado de juego, o indique en el comando transmitido al servidor un enlace al modelo que contiene el comando. Se parece a esto:

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

¿Y por qué, de hecho, es imposible tener un objeto enraizado en un lugar y referirse a él desde otro? Pero porque imagina que está deserializando un objeto de JSON, y aquí encontrará un enlace a un objeto enraizado en un lugar completamente diferente. Y todavía no hay lugar para eso, solo se creará a través del piso de deserialización. Ups No ofrezca ninguna deserialización de varios pasos. Esta es la limitación de este método. Por lo tanto, presentaremos un segundo método:

Todos los modelos creados por el segundo método se crean en un lugar mágico, y en todos los demás lugares del estado del juego solo se insertan enlaces en ellos. Durante la deserialización, si hay varias referencias al objeto, la primera vez que accede al lugar mágico, se crea el objeto y se devuelven todas las referencias posteriores al mismo objeto. Para implementar otras características, suponemos que el juego puede tener varios estados de juego, por lo que el lugar mágico no debería ser uno común, sino que debería ubicarse, por ejemplo, en el estado del juego. Para referencias a tales modelos, usamos otra variación del descriptor persistente PP. El modelo en sí será hecho más especial por Persistent: Model. En código, se verá más o menos así:

 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 poco engorroso, pero se puede usar. Para colocar pajillas, Persistent puede sujetar el constructor con el parámetro ModelRoot, lo que generará una alarma si intentan crear este modelo no a través de los métodos de este ModelRoot.

Tengo ambas opciones en mi código, y la pregunta es, ¿por qué usar la primera opción si la segunda cubre todos los casos posibles?

La respuesta es que el estado del juego debe ser, en primer lugar, legible por las personas. ¿Qué aspecto tiene si, si es posible, se utiliza la primera opción?

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

Y ahora, ¿cómo sería si solo se usara la segunda opción?
 { "persistents":{ "1":{"money":10, "inventory":2}, "2":{"capacity":11} }, "player":1 } 

Para depurar personalmente, prefiero la primera opción.

Acceder a las propiedades del modelo


El acceso a las instalaciones de almacenamiento reactivo para las propiedades al final resultó estar oculto bajo el capó del modelo. No es demasiado obvio cómo hacer que funcione para que funcione rápidamente, sin demasiado código en los modelos finales y sin demasiada reflexión. Echemos un vistazo más de cerca.

Lo primero que es útil saber sobre el diccionario es que leerlo no toma tanto tiempo constante, independientemente del tamaño del diccionario. Crearemos un diccionario estático privado en Model en el que a cada tipo de modelo se le asigne una descripción de los campos que contiene y accederemos a él una vez al construir el modelo. En el constructor de tipos, buscamos para ver si hay una descripción para nuestro tipo, de lo contrario, la creamos, si es así, tomamos la terminada. Por lo tanto, la descripción se creará solo una vez para cada clase. Al crear una descripción, colocamos en cada propiedad estática (descripción del campo) los datos extraídos mediante reflexión: el nombre del campo y el índice bajo el cual el almacén de datos para este campo estará en la matriz. De esta maneracuando se accede a través de la descripción del campo, su almacenamiento se eliminará de la matriz en un índice previamente conocido, es decir, rápidamente.

En el código, se verá así:

 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 } 

El diseño es un poco simple, porque los descriptores de propiedades estáticas declarados en los antepasados ​​de este modelo ya pueden tener índices de almacenamiento registrados, y el orden de devolución de propiedades de Type.GetFields () no está garantizado. Para el orden y para que las propiedades no se reinicialicen en dos veces, necesitas controlarte a ti mismo.

Propiedades de colección


En la sección del árbol modelo, se puede observar una construcción que no se mencionó anteriormente: PDictionaryModel <int, Persistent>: un descriptor para un campo que contiene una colección. Está claro que tendremos que crear nuestro propio repositorio de colecciones, que almacena información sobre cómo se veía la colección antes del inicio de la transacción y cómo se ve ahora. El guijarro bajo el agua aquí es del tamaño de una Piedra del Trueno debajo de Peter I. Consiste en el hecho de que, teniendo dos largos diccionarios a mano, es una tarea infernalmente costosa calcular la diferencia entre ellos. Supongo que tales modelos deberían usarse para todas las tareas relacionadas con meta, lo que significa que deberían funcionar rápidamente. En lugar de almacenar dos estados, clonarlos y luego compararlos costosamente, hago un enlace complicado: solo el estado actual del diccionario se almacena en la tienda. Otros dos diccionarios son valores eliminados,y valores antiguos de elementos reemplazados. Finalmente, se almacena un conjunto de claves nuevas agregadas al diccionario. Esta información se completa fácil y rápidamente. Es fácil generar todas las diferencias necesarias con ella y es suficiente para restaurar el estado anterior si es necesario. En código, se ve así:

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

No logré encontrar el mismo depósito maravilloso para la Lista, o no tengo suficiente tiempo, guardo dos copias. Se necesita un complemento adicional para tratar de minimizar el tamaño de la diferencia.

 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 sabe claramente lo que quiere recibir y cómo, puede escribir todo esto en unas pocas semanas. La velocidad de desarrollo del juego al mismo tiempo cambia tan dramáticamente que cuando lo probé, ni siquiera comencé mis propios juegos de creación de juegos sin tener un buen motor. Solo porque en el primer mes la inversión en él para mí obviamente valió la pena. Por supuesto, esto solo se aplica al meta. La jugabilidad debe hacerse a la antigua usanza.

En la siguiente parte del artículo, hablaré sobre comandos, redes y predicción de respuestas del servidor. Y también tengo algunas preguntas para ti que son muy importantes para mí. Si sus respuestas difieren de las que figuran entre paréntesis, con gusto las leeré en los comentarios o tal vez incluso escriba un artículo. Gracias de antemano por las respuestas.

PD Una propuesta de cooperación e instrucciones sobre numerosos errores de sintaxis, por favor en PM.

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


All Articles