Soluciones arquitectónicas para un juego móvil. Parte 2: Comando y sus colas



En la primera parte del artículo, examinamos cómo se debe organizar el modelo para que sea fácil de usar, pero depurarlo y atornillar interfaces es simple. En esta parte consideraremos la devolución de comandos para cambios en el modelo, en toda su belleza y diversidad. Como antes, la prioridad para nosotros será la conveniencia de la depuración, minimizando los gestos que un programador debe hacer para crear una nueva característica, así como la legibilidad del código para una persona.

Soluciones arquitectónicas para un juego móvil. Parte 1: modelo
Soluciones arquitectónicas para un juego móvil. Parte 3: Ver en el empuje del jet

Por qué comando


El patrón de comando suena fuerte, pero de hecho es solo un objeto en el que se agrega y almacena todo lo necesario para la operación solicitada. Elegimos este enfoque, al menos porque nuestros equipos serán enviados a través de la red, e incluso obtendremos algunas copias del estado del juego para uso oficial. Entonces, cuando el usuario hace clic en el botón, se crea una instancia de la clase de comando y se envía al destinatario. El significado de la letra C en la abreviatura MVC es algo diferente.

Predicción de resultados y verificación de comandos a través de la red.


En este caso, el código específico es menos importante que la idea. Y aquí está la idea:

Un juego respetuoso no puede esperar una respuesta del servidor antes de reaccionar al botón. Por supuesto, Internet está mejorando y puedes tener un montón de servidores en todo el mundo, y conozco incluso un par de juegos exitosos que esperan una respuesta del servidor, uno de ellos es incluso Invocando Guerras, pero aún así no necesitas hacer eso. Debido a que los retrasos de Internet móvil de 5-15 segundos son más propensos a ser la norma que una excepción, en Moscú al menos el juego debería ser realmente excelente para que los jugadores no le presten atención.

En consecuencia, tenemos un estado de juego que representa toda la información necesaria para la interfaz, y los comandos se aplican inmediatamente y solo después de eso se envían al servidor. Por lo general, los programadores de Java que trabajan duro se sientan en el servidor duplicando todas las nuevas funcionalidades una a una en otro idioma. En nuestro proyecto "ciervo", su número llegó a 3 personas, y los errores cometidos al portar fueron una fuente constante de alegría evasiva. En cambio, podemos hacerlo de manera diferente. Ejecutamos en el servidor .Net y ejecutamos en el lado del servidor el mismo código de comando que en el cliente.

El modelo descrito en el último artículo nos brinda una nueva oportunidad interesante para la autoevaluación. Después de ejecutar el comando en el cliente, calcularemos el hash del cambio que ocurrió en el árbol GameState y lo aplicaremos al equipo. Si el servidor ejecuta el mismo código de comando y el hash de los cambios no coincide, entonces algo salió mal.

Primeras ventajas:

  • Esta solución acelera enormemente el desarrollo y minimiza el número de programadores de servidores.
  • Si el programador cometió errores que condujeron a un comportamiento no determinista, por ejemplo, obtuvo el primer valor del Diccionario, o usó DateTime.now, y generalmente usó algunos valores que no están escritos explícitamente en los campos de comando, entonces cuando heh se inicia en el servidor, no coincidirán, y lo descubriremos
  • El desarrollo del cliente puede llevarse a cabo por el momento sin un servidor. Incluso puede entrar en alfa amigable sin tener un servidor. Esto es útil no solo para los desarrolladores independientes que se pierden el juego de sus sueños por la noche. Cuando estaba en Piksonik hubo un caso en el que el programador del servidor perdió todos los polímeros, y nuestro juego se vio obligado a sufrir moderación, teniendo en lugar del servidor un muñeco que defendía estúpidamente todo el estado del juego de vez en cuando.

Una desventaja que por alguna razón se subestima sistemáticamente:

  • Si el programador del cliente hizo algo mal y es invisible durante las pruebas, por ejemplo, la probabilidad de que haya productos en los cuadros misteriosos, entonces no hay nadie que escriba lo mismo por segunda vez y encuentre un error. El código autoportable requiere una actitud mucho más responsable hacia las pruebas.

Información de depuración detallada


Una de nuestras prioridades declaradas es la conveniencia de depuración. Si durante la ejecución del equipo atrapamos la ejecución: todo está claro, retrocedemos el estado del juego, enviamos el estado completo a los registros y serializamos el comando que lo dejó caer, todo es conveniente y hermoso. La situación es más complicada si tenemos una desincronización con el servidor. Debido a que el cliente ya ha completado varios otros comandos desde entonces, y resulta que no solo descubre en qué estado se encontraba el modelo antes de ejecutar el comando que condujo al desastre, sino que realmente quiero hacerlo. Clonar un estado de juego frente a cada equipo es demasiado complicado y costoso. Para resolver el problema, complicamos el esquema cosido debajo del capó del motor.

En el cliente no tendremos un estado de juego, sino dos. El primero sirve como interfaz principal para renderizar, los comandos se aplican inmediatamente. Después de eso, los comandos aplicados se ponen en cola para enviarlos al servidor. El servidor realiza la misma acción por su parte y confirma que todo está bien y es correcto. Una vez recibida la confirmación, el cliente toma el mismo comando y lo aplica al segundo estado del juego, llevándolo al estado que el servidor ya ha confirmado como correcto. Al mismo tiempo, también tenemos la oportunidad de comparar el hash de los cambios realizados para que sea seguro, y también podemos comparar el hash completo de todo el árbol en el cliente, que podemos calcular después de ejecutar el comando, pesa un poco y se considera lo suficientemente rápido. Si el servidor no dice que todo está bien, le pide al cliente detalles de lo que sucedió, y el cliente puede enviarle un segundo estado de juego serializado exactamente como se veía antes de que el comando se ejecutara con éxito en el cliente.
La solución parece muy atractiva, pero da lugar a dos problemas que deben resolverse a nivel de código:

  • Entre los parámetros de comando, puede haber no solo tipos simples, sino también enlaces a modelos. En otro estado de juego, exactamente en el mismo lugar se encuentran otros objetos del modelo. Resolvemos este problema de la siguiente manera: antes de ejecutar el comando en el cliente, serializamos todos sus datos. Entre ellos puede haber enlaces a modelos, que escribiremos en forma de Path to the model desde la raíz del estado del juego. Hacemos esto ante el equipo, porque después de su ejecución los caminos pueden cambiar. Luego enviamos esta ruta al servidor, y el estado del juego del servidor podrá obtener un enlace a su modelo en el camino. De manera similar, cuando un equipo se aplica al segundo estado del juego, el modelo se puede obtener del segundo estado del juego.
  • Además de los tipos y modelos elementales, un equipo puede tener enlaces a colecciones. Diccionario <clave, Modelo>, Diccionario <Modelo, clave>, Lista <Modelo>, Lista <Valor>. Para todos, tienen que escribir serializadores. Es cierto que no puede precipitarse en esto, en un proyecto real, tales campos surgen sorprendentemente raramente.
  • Enviar comandos al servidor de uno en uno no es una buena idea, porque el usuario puede producirlos más rápido de lo que Internet puede arrastrarlos de un lado a otro, en Internet deficiente crecerá el conjunto de comandos que el servidor no ha elaborado. En lugar de enviar comandos uno a la vez, los enviaremos en lotes de varias piezas. En este caso, después de recibir una respuesta del servidor de que algo salió mal, primero deberá aplicar al segundo estado todos los comandos anteriores del mismo paquete que fueron confirmados por el servidor, y solo luego borrar y enviar el segundo estado de control al servidor.

Comodidad y facilidad para escribir comandos


El código de ejecución del comando es el segundo código más grande y el primero más responsable del juego. Cuanto más simple y claro sea, y cuanto menos necesite el programador hacer un extra con sus manos para escribirlo, más rápido se escribirá el código, menos errores se cometerán y, muy inesperadamente, más feliz será el programador. Coloco el código de ejecución directamente en el comando en sí, además de las piezas y funciones generales que se encuentran en clases de reglas estáticas separadas, con mayor frecuencia en forma de extensiones a las clases de modelo con las que trabajan. Te mostraré un par de ejemplos de comandos de mi proyecto favorito, uno muy simple y el otro un poco más complicado:

namespace HexKingdoms { public class FCSetSideCostCommand : HexKingdomsCommand { //              protected override bool DetaliedLog { get { return true; } } public FCMatchModel match; public int newCost; protected override void HexApply(HexKingdomsRoot root) { match.sideCost = newCost; match.CalculateAssignments(); match.CalculateNextUnassignedPlayer(); } } } 

Y aquí está el registro que este comando deja después de sí mismo, si este registro no está deshabilitado para él.

 [FCSetSideCostCommand id=1 match=FCMatchModel[0] newCost=260] Execute:00:00:00.0027546 Apply:00:00:00.0008689 { "LOCAL_PERSISTENTS":{ "@changed":{ "0":{"SIDE_COST":260}, "1":{"POSSIBLE_COST":260}, "2":{"POSSIBLE_COST":260}}}} 

La primera vez que se indica en el registro es el tiempo durante el cual se realizaron todos los cambios necesarios en el modelo, y la segunda es el tiempo durante el cual los controladores de interfaz resolvieron todos los cambios. Esto debe mostrarse en el registro para no hacer accidentalmente algo terriblemente lento o para darse cuenta a tiempo si las operaciones comienzan a tomar demasiado tiempo simplemente por el tamaño del modelo en sí.

Además de las llamadas a objetos persistentes en Id-shniks, que reducen en gran medida la legibilidad del registro, que, por cierto, podría haberse evitado aquí, el código de comando en sí y el registro que hizo con el estado del juego son increíblemente claros. Tenga en cuenta que en el texto del comando el programador no realiza un solo movimiento adicional. Todo lo que necesita lo hace el motor debajo del capó.

Ahora veamos un ejemplo de un equipo más grande

 namespace HexKingdoms { public class FCSetUnitForPlayerCommand : HexKingdomsCommand { //            protected override bool DetaliedLog { get { return true; } } public FCSelectArmyScreenModel screen; public string unit; public int count; protected override void HexApply(HexKingdomsRoot root) { if (count == 0 && screen.player.units.ContainsKey(unit)) { screen.player.units.Remove(unit); screen.selectedUnits.Remove(unit); } else if (count != 0) { if (screen.player.units.ContainsKey(unit)) { screen.player.units[unit] = count; screen.selectedUnits[unit].count = count; } else { screen.player.units.Add(unit, count); screen.selectedUnits[unit] = new ReferenceUnitModel() { type = unit, count = count }; } } screen.SetSelectedReferenceUnits(); screen.player.CalculateUnitsCost(); var side = screen.match.sides[screen.side]; screen.match.CalculatePlayerAssignmentsAcceptablity(side); screen.match.CalculateNextUnassignedPlayer(screen.player); } } } 

Y aquí está el registro dejado por el equipo:

 [FCSetUnitForPlayerCommand id=3 screen=/UI_SCREENS[main] unit=militia count=1] Execute:00:00:00.0065625 Apply:00:00:00.0004573 { "LOCAL_PERSISTENTS":{ "@changed":{ "2":{ "UNITS":{ "@set":{"militia":1}}, "ASSIGNED":7}}}, "UI_SCREENS":{ "@changed":{ "main":{ "SELECTED_UNITS":{ "@set":{ "militia":{"@new":null, "TYPE":"militia", "REMARK":null, "COUNT":1, "SELECTED":false, "DISABLED":false, "HIGHLIGHT_GREEN":false, "HIGHLIGHT_RED":false, "BUTTON_ENABLED":false}}}}}}} 

Como dicen, es mucho más claro. Tómese el tiempo para equipar al equipo con un registro conveniente, compacto e informativo. Esta es la clave de tu felicidad. El modelo debe funcionar muy rápido, por lo que utilizamos una variedad de trucos con métodos de almacenamiento y acceso a los campos. Los comandos se ejecutan en el peor de los casos una vez por cuadro, de hecho, varias veces con menos frecuencia, por lo que haremos la serialización y deserialización de los campos de comando sin ningún tipo de fantasía, solo a través de la reflexión. Solo clasificamos los campos por nombres para que el orden sea fijo, bueno, compilaremos la lista de campos una vez durante la vida del comando y leeremos y escribiremos usando métodos nativos C #.

Modelo de información para la interfaz.


Vamos a dar el siguiente paso para complicar nuestro motor, un paso que parece aterrador, pero simplifica enormemente la escritura y la depuración de las interfaces. Muy a menudo, especialmente en el patrón MVP relacionado, el modelo contiene solo lógica de negocios controlada por el servidor, y la información sobre el estado de la interfaz se almacena dentro del presentador. Por ejemplo, desea pedir cinco boletos. Ya seleccionó su número, pero aún no ha hecho clic en el botón "ordenar". La información sobre exactamente cuántos boletos ha elegido en el formulario se puede almacenar en algún lugar de los rincones secretos de la clase, lo que sirve como una junta entre el modelo y su pantalla. O, por ejemplo, el jugador cambia de una pantalla a otra, pero nada cambia en el modelo, y dónde estaba cuando ocurrió la tragedia, el programador de depuración solo lo sabe por las palabras de un probador extremadamente disciplinado. El enfoque es simple, comprensible, casi siempre usado y un poco malicioso, en mi opinión. Porque si algo salió mal, el estado de este presentador, que condujo a un error, es absolutamente imposible de descubrir. Especialmente si el error ocurrió en el servidor de batalla durante la operación por $ 1000, y no en el probador en un entorno controlado y reproducible.

En lugar de este enfoque habitual, prohibimos que cualquier persona, excepto el modelo, contenga información sobre el estado de la interfaz. Esto tiene, como de costumbre, ventajas y desventajas que deben ser combatidas.

  • (+1) La ventaja más importante, ahorrar meses de trabajo de programación: si algo sale mal, el programador simplemente carga el estado del juego antes del accidente y recibe exactamente el mismo estado no solo del modelo de negocio, sino de toda la interfaz hasta el último botón de la pantalla.
  • (+2) Si algún equipo cambió algo en la interfaz, el programador puede ir fácilmente al registro y ver qué ha cambiado exactamente en un conveniente formulario json, como en la sección anterior.
  • (-1) En el modelo aparece una gran cantidad de información redundante que no es necesaria para comprender la lógica comercial del juego y que el servidor no la necesita dos veces.

Para resolver este problema, marcaremos algunos campos como notServerVerified, se ve así, por ejemplo, así:

 public EDictionary<string, UIStateModel> uiScreens { get { return UI_SCREENS.Get(this); } } public static PDictionaryModel<string, UIStateModel> UI_SCREENS = new PDictionaryModel<string, UIStateModel>() { notServerVerified = true }; 

Esta parte del modelo y todo lo que está debajo se relacionará exclusivamente con el cliente.

Si aún recuerda, los indicadores de lo que necesita exportar y lo que no se ve así:

 [Flags] public enum ExportMode { all = 0x0, changes = 0x1, serverVerified = 0x2 } 

En consecuencia, al exportar o calcular un hash, puede especificar si exporta el árbol completo o solo esa parte que el servidor verifica.

La primera complicación obvia que surge de aquí es la necesidad de crear comandos separados que el servidor debe verificar y aquellos que no son necesarios, pero también hay otros que deben verificarse no del todo. Para no cargar al programador con operaciones innecesarias para configurar el comando, intentaremos nuevamente hacer todo lo necesario con el capó del motor.

 public partial class Command { /** <summary>    ,      </summary> */ public virtual void Apply(ModelRoot root) {} /** <summary>         </summary> */ public virtual void ApplyClientSide(ModelRoot root) {} } 

El programador que crea el comando puede anular una o ambas funciones. Todo esto, por supuesto, es maravilloso, pero ¿cómo puedo asegurarme de que el programador no estropeó nada, y si arruinó algo, ¿cómo puede ayudarlo a solucionarlo rápida y fácilmente? Hay dos formas Apliqué el primero, pero puede que te guste el segundo más.

Primera forma


Utilizamos las características interesantes de nuestro modelo:

  1. El motor llama a la primera función, después de lo cual recibe un hash de cambios en la parte del estado del juego verificada por el servidor. Si no hay cambios, entonces estamos tratando exclusivamente con el equipo del cliente.
  2. Obtenemos el hash modelo de cambios en todo el modelo, no solo el verificado por el servidor. Si difiere del hash anterior, el programador cometió un error y cambió algo en la parte del modelo que el servidor no comprobó. Rodeamos el árbol de estado y volcamos al programador como una ejecución de una lista completa de los campos notServerVerified = true y los que se encuentran debajo del árbol que él cambió.
  3. Llamamos a la segunda función. Obtenemos del modelo un resumen de los cambios que ocurrieron en la parte marcada. Si no coincide con el hash después de la primera llamada, en la segunda función el programador ha hecho algo. Si queremos obtener un registro muy informativo en este caso, revertimos todo el modelo a su estado original, lo serializamos en un archivo, luego el programador será útil para la depuración, luego lo clonará completo (dos líneas - serialización-deserialización), y ahora primero aplicamos el primero función, luego confirmamos los cambios para que el modelo se vea sin cambios, después de lo cual aplicamos la segunda función. Y luego exportamos todos los cambios en la parte comprobada por el servidor en forma de JSON y los incluimos en la ejecución abusiva, para que el programador avergonzado pueda ver de inmediato qué y dónde cambió, qué no se debe cambiar.

Parece, por supuesto, aterrador, pero de hecho son 7 líneas, porque las funciones que hacen esto es todo (excepto atravesar el árbol desde el segundo párrafo) estamos listos. Y como se trata de recepción, podemos permitirnos actuar de manera no óptima.

Segunda forma


Un poco más brutal, ahora en ModelRoot tenemos un campo de bloqueo, pero podemos dividirlo en dos, uno bloqueará solo los campos marcados en el servidor y el otro solo los campos marcados. En este caso, el programador que hizo algo mal recibirá una explicación al respecto de inmediato con un vínculo con el lugar donde lo hizo. El único inconveniente de este enfoque es que si en nuestro árbol una propiedad modelo está marcada como desmarcada, entonces todo lo que se encuentra en el árbol debajo de él con respecto al cálculo de hashes y el control de cambios no se inspeccionará, incluso si cada campo no se marcó. Un bloqueo, por supuesto, no mirará dentro de la jerarquía, lo que significa que todos los campos de la parte no marcada del árbol tendrán que estar marcados, y no funcionará en algunos lugares para usar las mismas clases en la interfaz de usuario y la parte habitual del árbol. Como opción, tal construcción es posible (la escribiré simplificada):

 public class GameState : Model { public RootModelData data; public RootModelLocal local; } public class RootModel { public bool locked { get; } } 

Entonces resulta que cada subárbol tiene su propio bloqueo. GameState hereda modelos, porque es más fácil que crear una implementación separada de la misma funcionalidad.

Mejoras necesarias


Por supuesto, el gerente responsable del procesamiento de los equipos tendrá que agregar una nueva funcionalidad. La esencia de los cambios será que no todos los comandos se enviarán al servidor, sino solo aquellos que crean los cambios marcados. El servidor de su lado no elevará todo el árbol de estado del juego, sino solo la parte que se verifica y, en consecuencia, el hash coincidirá solo para la parte que se verifica. Cuando se ejecuta el comando, solo la primera de las dos funciones del comando se iniciará en el servidor, y al resolver referencias a modelos en el estado del juego, si la ruta conduce a una parte no verificable del árbol, se colocará un valor nulo en la variable del comando en lugar del modelo. Todos los equipos que no envían se alinearán honestamente con los habituales, pero se considerarán confirmados. Tan pronto como lleguen a la línea y no haya personas sin confirmar antes de ellos, se aplicarán inmediatamente al segundo estado.

No hay nada fundamentalmente complicado en la implementación. Es solo que la propiedad de cada campo del modelo tiene una condición más, un recorrido del árbol.

Otro refinamiento necesario: necesitará una Fábrica separada para ParsistentModel en las partes marcadas y no marcadas del árbol y NextFreeId para ellas será diferente.

Comandos iniciados por el servidor


Hay algún problema si el servidor quiere enviar su comando al cliente, porque el estado del cliente en relación con el servidor ya podría avanzar unos pasos. La idea principal es que si el servidor necesita enviar su comando, envía la notificación del servidor al cliente con la siguiente respuesta, y lo escribe en el campo para las notificaciones enviadas a este cliente. El cliente recibe una notificación, forma un comando sobre la base y lo coloca al final de su cola, después de aquellos que se han completado en el cliente pero aún no han llegado al servidor. Después de un tiempo, el comando se envía al servidor como parte del proceso normal de trabajo con el modelo. Habiendo recibido este comando para el procesamiento, el servidor arroja la notificación fuera de la cola saliente. Si el cliente no respondió a la notificación dentro del tiempo establecido con el siguiente paquete, se le envía un comando de reinicio. Si el cliente que recibió la notificación se ha caído, se conecta más tarde o, por alguna razón, carga el juego, entonces el servidor convertirá todas las notificaciones en comandos antes de darle el estado, las ejecutará de su lado y solo después de eso le dará al cliente que se une su nuevo estado. Tenga en cuenta que un jugador puede tener un estado conflictivo con recursos negativos cuando el jugador logró gastar el dinero exactamente en el momento en que el servidor se los quitó. La coincidencia es poco probable, pero con una DAU grande, es casi inevitable. Por lo tanto, la interfaz y las reglas del juego no deben morir en tal situación.

Comandos para ejecutar que necesita saber la respuesta del servidor


Un error típico es pensar que solo se puede obtener un número aleatorio del servidor. Nada le impide tener el mismo generador de números pseudoaleatorio ejecutándose simultáneamente desde el cliente y el servidor, comenzando desde un sid común. Además, la semilla actual se puede almacenar directamente en el estado del juego. A algunos les puede resultar difícil sincronizar la respuesta de este generador. De hecho, para esto es suficiente tener un número más en el mismo artículo, exactamente cuántos números se recibieron del generador hasta este momento. Si su generador por alguna razón no converge, entonces tiene un error en alguna parte y el código no funciona de manera determinista. Y este hecho no debe ocultarse debajo de la alfombra, sino que debe resolverse y buscar un error. Para la gran mayoría de los casos, incluso las cajas misteriosas, este enfoque es suficiente.

Sin embargo, hay momentos en que esta opción no es adecuada. Por ejemplo, estás jugando un premio muy costoso y no quieres que el astuto compañero descompile el juego, y escribe un bot que te diga de antemano qué se caerá de la caja de diamantes si lo abres ahora mismo, y qué pasa si giras el tambor en otro lugar antes de eso. Puede almacenar semillas para cada variable aleatoria por separado, esto protegerá contra la piratería frontal, pero no ayudará de ninguna manera a un bot que le diga cuántas cajas tiene el producto que necesita actualmente. Bueno, el caso más obvio es que es posible que no desee brillar en la configuración del cliente con información sobre la probabilidad de algún evento raro. En resumen, a veces es necesario esperar una respuesta del servidor.
Dichas situaciones deben resolverse no a través de las capacidades adicionales del motor, sino dividiendo al equipo en dos: la primera prepara la situación y pone la interfaz en un estado de espera para notificaciones, la segunda en realidad, con la respuesta que necesita. Incluso si bloquea estrictamente la interfaz entre ellos en el cliente, puede pasar otro comando, por ejemplo, una unidad de energía se restaurará a tiempo.

Es importante comprender que tales situaciones no son la regla, sino la excepción. De hecho, la mayoría de los juegos solo necesitan un equipo esperando una respuesta: GetInitialGameState. Otro paquete de tales comandos es la interacción entre jugadores en un metajuego, GetLeaderboard, por ejemplo. Todas las otras doscientas piezas son deterministas.

Almacenamiento de datos del servidor y el tema turbio de la optimización del servidor


Admito de inmediato que soy un cliente, y a veces he escuchado tales ideas y algoritmos de servidores de servidores familiares que ni siquiera se me han metido en la cabeza. Al comunicarme con mis colegas, de alguna manera desarrollé una imagen de cómo debería funcionar mi arquitectura en el lado del servidor en el caso ideal. Sin embargo: existen contraindicaciones, es necesario consultar con un servidor especializado.

Primero sobre el almacenamiento de datos. Es su lado del servidor el que puede tener restricciones adicionales. Por ejemplo, se le puede prohibir el uso de campos estáticos. Además, el código de comandos y modelos es autoportable, pero el código de propiedad en el cliente y en el servidor no tiene que coincidir en absoluto. Allí se puede ocultar cualquier cosa, hasta la inicialización diferida de los valores de campo de la memoria caché, por ejemplo. Los campos de propiedades también pueden recibir parámetros adicionales que son utilizados por el servidor, pero no afectan el trabajo del cliente.

La primera diferencia cardinal del servidor: donde los campos se serializan y deserializan. Una solución razonable es que la mayoría del árbol de estado se serializa en un gran campo binario o json. Al mismo tiempo, algunos campos se toman de las tablas. Esto es necesario porque los valores de algunos campos serán constantemente necesarios para que funcionen los servicios de interacción entre jugadores. Por ejemplo, el ícono y el nivel se mueven constantemente por una variedad de personas. Se guardan mejor en una base de datos regular. Raramente, cuando alguien decide buscar en su territorio, necesitará un estado completo o parcial, pero detallado de una persona que no sea él.

Además, extraer los campos de la base uno a la vez es inconveniente, y puede llegar a arrastrar todo durante mucho tiempo. Una solución muy no estándar, disponible solo para nuestra arquitectura, puede consistir en el hecho de que el cliente, cuando ejecuta un comando, recopila información sobre todos los campos almacenados por separado en tablas cuyos captadores lograron tocar y agrega esta información al comando para que el servidor pueda generar este grupo de campos Una solicitud a la base de datos. Por supuesto, con restricciones razonables, para no rogar por DDOS causado por programadores curvos que tocaron todo sin prestar atención.

Con dicho almacenamiento separado, uno debería considerar los mecanismos de transaccionalidad cuando un jugador se arrastra a los datos de otro, por ejemplo, le roba dinero. Pero en el caso general, lo hacemos mediante notificación. Es decir, el ladrón recibe su dinero de inmediato, y la persona robada recibe una notificación con instrucciones para cancelar el dinero cuando se trata de eso.

Cómo se dividen los equipos entre servidores


Ahora es el segundo momento importante para el servidor. Hay dos enfoques. Al principio, para procesar cualquier solicitud (o un paquete de solicitudes), todo el estado se eleva desde la base de datos o caché a la memoria, se procesa y luego se devuelve a la base de datos. Las operaciones se resuelven atómicamente en un grupo de servidores de ejecución diferentes, y solo tienen una base común, y aun así no siempre. Como cliente, elevar todo el estado a cada equipo es impactante, pero vi cómo funciona, y funciona de manera muy confiable y escalable. La segunda opción es que el estado una vez aumenta en la memoria y permanece allí hasta que el cliente se cae solo ocasionalmente agregando su estado actual a la base de datos.No soy competente para decirle las ventajas y desventajas de este o aquel método. Será bueno que alguien en los comentarios me explique por qué el primero tiene derecho a la vida en general. La segunda opción plantea preguntas sobre cómo interactuar entre jugadores que por casualidad se criaron en diferentes servidores. Esto puede ser crítico, por ejemplo, si varios miembros del clan se están preparando para lanzar un ataque conjunto. No puede mostrar a otros el estado de su miembro del grupo con un retraso de 10 salvados. Desafortunadamente, no lo abriré aquí, la interacción a través de las notificaciones descritas anteriormente, los comandos de un servidor a otro, en este momento está fuera de turno para guardar el estado actual del jugador planteado allí. Si los servidores tienen el mismo nivel de disponibilidad desde diferentes lugares,y puede administrar el equilibrador, puede intentar transferir silenciosamente el reproductor de un servidor a otro. Si conoce mejor una solución, asegúrese de describirla en los comentarios.

Baila con el tiempo


Comencemos con la pregunta, que realmente me gusta derribar a las personas en las entrevistas: aquí tiene un cliente y un servidor, cada uno tiene su propio reloj bastante preciso. Cómo averiguar cuánto difieren. Un intento de resolver este problema en una cafetería con una servilleta revela las mejores y peores cualidades de un programador. El hecho es que el problema no tiene una solución formal matemáticamente correcta. Pero el entrevistado es consciente de esto, por lo general, tome un minuto en el quinto y solo después de las preguntas principales. Y la forma en que conoce esta idea y lo que hace a continuación, dice mucho sobre lo más importante de su personaje, lo que hará esta persona cuando comiencen los problemas reales en su proyecto.

La mejor solución que conozco me permite descubrir no la diferencia exacta, sino aclarar el rango en el que cae a través de muchas solicitudes de respuesta hasta el momento del mejor paquete que se ejecutó de cliente a servidor, más el tiempo del mejor paquete que se ejecutó de servidor a cliente. En total, esto le dará algunas decenas de milisegundos de precisión. Esto es muchas veces mejor de lo necesario para el metajuego del juego móvil, aquí no tenemos multijugador de realidad virtual o CS, pero todavía es bueno para el programador representar la escala y la naturaleza de las dificultades con la sincronización del reloj. Lo más probable es que sea suficiente para que sepa el retraso promedio tomado como un ping a la mitad, durante mucho tiempo con un límite de desviaciones de más del 30%.

La segunda situación interesante que es probable que encuentres es organizar un juego de deslizamiento y transferir el reloj a tu teléfono. En ambos casos, el tiempo en la aplicación cambiará dramáticamente y abruptamente, y esto debe resolverse correctamente. Al menos reinicie el juego, pero es mejor, por supuesto, no reiniciar después de cada deslizamiento, por lo que no puede usar el tiempo que ha pasado en la aplicación desde su lanzamiento.

En tercer lugar, la situación, por alguna razón, es un problema que algunos programadores deben comprender, aunque existe una solución correcta: las operaciones no pueden realizarse absolutamente en tiempo de servidor. Por ejemplo, comience la producción de bienes cuando llegue una solicitud de producción al servidor. De lo contrario, despídase de su determinismo y obtenga 35 mil desincronizaciones por día causadas por diferentes opiniones del cliente y el servidor sobre si ya es posible hacer clic en el premio. La decisión correcta es que el equipo registra información sobre el momento en que se ejecutó. El servidor, a su vez, verifica si la diferencia horaria entre la hora actual del servidor y la hora en el comando cae dentro del intervalo permitido, y si lo hace, ejecuta el comando por su parte usando el tiempo declarado por el cliente.
Otra tarea para la entrevista: Tiempo de espera después del cual el cliente intentará reiniciar - 30 segundos. ¿Cuáles son los límites de la diferencia horaria aceptable para el servidor? Consejo # 1: El intervalo no es simétrico. Consejo # 2: Vuelva a leer el primer párrafo de esta sección nuevamente, especifique cómo extender el intervalo para no detectar 3000 errores por día en los efectos de borde.

Para que esto funcione de manera hermosa y correcta, es mejor agregar un parámetro adicional a los parámetros de llamada de comando: el tiempo de llamada. Algo como esto:

 public interface Command { void Apply(ModelRoot root, long time); } 

Y mi consejo para ti, por cierto, no uses tipos de Unidad nativos por tiempo en el modelo: te ahogarás. Es mejor almacenar UnixTime en tiempo de servidor, siempre que necesite tener a mano métodos de conversión a mano, y almacenarlos en el modelo en un campo PTime especial que difiera de PValue <long> solo en que al exportar a JSON agrega información redundante entre paréntesis que no se puede leer cuando Importar: Tiempo en un formato legible para humanos. No puedes obedecerme. Te lo advertí

Cuarta situación: en el estado del juego, hay situaciones en las que un equipo debe iniciarse sin la participación de un jugador, a tiempo, por ejemplo, la recuperación de energía. Una situación muy común, de hecho. Quiero tener un campo, está practicando convenientemente. Por ejemplo, PTimeOut, en el que será posible grabar un punto en el tiempo después del cual se debe crear y ejecutar un comando. En el código, puede verse así:

 public class MyModel : Model { public static PTimeOut RESTORE_ENERGY = new PTimeOut() {command = (model, property) => new RestoreEnergyCommand() { model = model}} public long restoreEnergy { get { return RESTORE_ENERGY.Get(this); } set { RESTORE_ENERGY.Set(this, value); }} } 

Por supuesto, durante la carga inicial del reproductor, el servidor primero debe provocar la creación y ejecución de todos estos comandos, y solo entonces debe entregar el estado al jugador. El peligro aquí es que todo esto interfiere con las notificaciones que el jugador podría recibir durante este tiempo. Por lo tanto, será necesario desenroscar primero el tiempo anterior a la hora de la primera notificación, si necesita extraer un montón de comandos al mismo tiempo, luego crear un comando desde la notificación en sí, luego desenroscar el tiempo hasta la próxima notificación, luego resolverlo y así sucesivamente. Si todo este feriado de la vida no encaja en el tiempo de espera del servidor, y esto es posible si el jugador recibió muchas notificaciones, escribimos el estado actual de la memoria a la base de datos y respondemos con un comando para volver a conectar con el cliente.

Todos estos comandos deben de alguna manera aprender sobre lo que necesitan crear y ejecutar. Mi solución un poco crujiente pero conveniente es que el modelo tiene un desafío más, que se extiende por toda la jerarquía del modelo, que se mueve después de ejecutar cada comando y también en el temporizador. Por supuesto, esta es una sobrecarga adicional para caminar alrededor del árbol casi en una actualización, en lugar de eso, puede suscribirse o darse de baja del evento CurrentTime que sobresale del estado del juego con cada cambio en este campo:

 public partial class Model { public void SetCurrentTime(long time); } vs public partial class RootModel { public event Action<long> setCurrentTime; } 

Esto es bueno, pero el problema es que los modelos que se eliminan del árbol de modelos para siempre y que contienen dicho campo permanecerán suscritos a este evento, y tendrán que resolverlo correctamente. Antes de enviar un comando, verifique si todavía están en el árbol y si tienen un enlace débil a este evento o inversión de control, de modo que debido a esto no sean inaccesibles para GC.

Apéndice 1, un caso típico tomado de la vida real


Tomo de los comentarios a la primera parte. En los juguetes, muy a menudo algunas acciones no tienen lugar inmediatamente después del comando al modelo, sino al final de algún tipo de animación. En nuestra práctica, hubo un caso en el que se abrieron las cajas misteriosas y, por supuesto, el dinero debería cambiar solo cuando la animación se desarrolla hasta el final. Uno de nuestros desarrolladores decidió simplificar su vida, y no cambiar el valor en el comando, pero decirle al servidor que cambió, y al final de la animación ejecutar una devolución de llamada, que corregirá los valores en el modelo a los deseados. Bien hecho, en resumen. Hizo estas cajas misteriosas durante dos semanas, y luego otros tres errores difíciles de atrapar que surgieron como resultado de su trabajo y tuvimos que pasar tres semanas más para completarlos, a pesar de que el tiempo para "reescribir como normal" fue, por supuesto, nadie pudo destacar. De lo cual se desprende vívidamenteCreo que la conclusión es que era mejor hacer todo desde el principio con una reactividad normal.

Entonces, mi decisión es algo como esto. Por supuesto, el dinero no se encuentra en un campo separado, sino que es uno de los objetos en el diccionario de inventario, pero esto no es tan importante en este momento. El modelo tiene una parte que es verificada por el servidor, y sobre la base de la cual funciona la lógica de negocios, y la otra que existe solo en el cliente. El dinero en el modelo principal se acumula inmediatamente después de tomar la decisión, y en la segunda parte de la lista de "presentación diferida", se crea un elemento por la misma cantidad que inicia la animación por el hecho de su aparición, y cuando la animación termina, se lanza un comando que elimina este elemento. Tal nota puramente del cliente "todavía no muestra esta cantidad". Y en un campo real, no solo se muestra el valor del campo, sino el valor del campo menos todos los aplazamientos del cliente. La división en esos dos equipos se hace porque¿Qué pasa si el cliente se reinicia después del primer equipo, pero antes del segundo todo el dinero recibido por el jugador estará en su cuenta sin ninguna marca y excepción? En el código, será algo como esto:

 public class OpenMisterBox : Command { public BoxItemModel item; public int slot; //        ,  . public override void Apply(GameState state) { state.inventory[item.revardKey] += item.revardCount; } //       . public override void Apply(GameState state) { var cause = state.NewPersistent<WaitForCommand>(); cause.key = item.key; cause.value = item.value; state.ui.delayedInventoryVisualization.Add(cause); state.ui.mysteryBoxScreen.animations.Add(new Animation() {cause = item, slot = slot})); } } public class MysteryBoxView : View { /* ... */ public override void ConnectModel(MysteryBoxScreenModel model, List<Control> c) { model.Get(c, MysteryBoxScreenModel.ANIMATIONS) .Control(c, onAdd = item => animationFactory(item, OnComleteOrAbort => { AsincQueue(new RemoveAnimation() {cause = item.cause, animation = item}) }), onRemove = item => {} ) } } public class InventoryView : View<InventoryItem> { public Text text; public override void ConnectModel(InventoryItem model, List<Control> c) { model.GameState.ui.Get(c, UIModel.DELAYED_INVENTORY_VISUALIZATION). .Where(c, item => item.key == model.key) .Expression(c, onChange = (IList<InventoryItem> list) => { int sum = 0; for (int i = 0; i < list.Count; i++) sum += list[i].value; return sum; }, onAdd = null, onRemove = null ) //      .Join (c, model.GameState.Get(GameState.INVENTORY).ItemByKey(model.key)) .Expression(c, (delay, count) => count - delay) .SetText(c, text); //     ,      ,   ,  ,   ,       ,     : model.inventory.CreateVisibleInventoryItemCount(c, model.key).SetText(c, text); } } public class RemoveDelayedInventoryVisualization : Command { public DelayCauseModel cause; public override void Apply(GameState state) { state.ui.delayedInventoryVisualization.Remove(cause); } } public class RemoveAnimation : RemoveDelayedInventoryVisualization { public Animation animation public override void Apply(GameState state) { base.Apply(state); state.ui.mysteryBoxScreen.animations.Remove(animation); } } 

¿Qué tenemos al final?Hay dos vistas, en una de ellas se reproduce una animación, cuyo final está esperando que el dinero se muestre en una vista completamente diferente, que no tiene idea de quién y por qué quiere mostrar un significado diferente. Todo es reactivo. En cualquier momento, puede cargar el estado completo de GameState en el juego y comenzará a reproducirse exactamente desde donde lo dejamos, incluida la animación que se inicia. La verdad comenzará desde el principio, porque no borramos la etapa de animación, pero si realmente la necesitamos, podemos borrarla incluso.

Total


Diseñar la lógica empresarial del juego a través de modelos, equipos y archivos estáticos con reglas, encerrándolos por todos lados con hermosos registros detallados y generados automáticamente y adjuntando ejecuciones informativas de muchos errores típicos cometidos por un programador que muestra nuevas características, esta es, en mi opinión, la forma correcta de vivir. luz blanca Y no solo porque puede presentar nuevas funciones varias veces más rápido. Esto sigue siendo muy importante, porque si te resulta fácil descargar y depurar una nueva función, entonces el diseñador del juego tendrá tiempo de realizar varias veces más experimentos de diseño de juegos con los mismos programadores. Con el debido respeto a nuestro trabajo, depende de nosotros si el juego falla o no, pero si dispara o no depende de los gamedismos, y necesitan espacio para los experimentos.
Y ahora te pido que respondas preguntas muy importantes para mí. Si tiene ideas sobre cómo hacer lo que he hecho mal, o simplemente desea comentar mis respuestas, los estoy esperando en los comentarios. Una propuesta de cooperación e instrucciones sobre numerosos errores de sintaxis, por favor en PM.

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


All Articles