Interacci贸n cliente-servidor en un nuevo dispositivo m贸vil PvP shooter y servidor de juegos: problemas y soluciones

En art铆culos anteriores de la serie (todos los enlaces al final del art铆culo) sobre el desarrollo de un nuevo tirador de ritmo r谩pido, examinamos los mecanismos de la arquitectura principal de la l贸gica del juego basada en ECS, y las caracter铆sticas de trabajar con un tirador en el cliente, en particular, la implementaci贸n de un sistema para predecir las acciones locales del jugador para aumentar la capacidad de respuesta del juego. . Esta vez nos detendremos en m谩s detalles sobre cuestiones de interacci贸n cliente-servidor en condiciones de mala conexi贸n de redes m贸viles y formas de mejorar la calidad del juego para el usuario final. Tambi茅n describir茅 brevemente la arquitectura del servidor del juego.




Durante el desarrollo del nuevo PvP s铆ncrono para dispositivos m贸viles, encontramos problemas t铆picos del g茅nero:

  1. La calidad de conexi贸n de los clientes m贸viles es deficiente. Este es un ping promedio relativamente alto en la regi贸n de 200-250 ms, y una distribuci贸n de tiempo inestable del ping teniendo en cuenta el cambio de puntos de acceso (aunque, contrariamente a la creencia popular, el porcentaje de p茅rdida de paquetes en redes m贸viles 3G + es bastante bajo, aproximadamente 1%).
  2. Las soluciones t茅cnicas existentes son marcos monstruosos que llevan a los desarrolladores a marcos ajustados.

Creamos el primer prototipo en UNet, a pesar de que impuso restricciones en la escalabilidad, el control sobre el componente de red y agreg贸 dependencia en la conexi贸n caprichosa de los clientes maestros. Luego cambiamos a un c贸digo de red autoescrito en la parte superior de Photon Server , pero m谩s sobre eso m谩s adelante.

Considere los mecanismos para organizar las interacciones entre clientes en juegos PvP sincr贸nicos. El m谩s popular de ellos:

  • P2P o de igual a igual . Toda la l贸gica del partido est谩 alojada en uno de los clientes y no requiere casi ning煤n costo de tr谩fico de nuestra parte. Pero el alcance de los tramposos y los altos requisitos para el cliente que aloja el partido, as铆 como las limitaciones de NAT no nos permitieron tomar esta soluci贸n para un juego m贸vil.
  • Cliente-servidor . Un servidor dedicado, por el contrario, le permite controlar completamente todo lo que sucede en el partido (adi贸s, tramposos), y su rendimiento le permite calcular algunas cosas espec铆ficas de nuestro proyecto. Adem谩s, muchos proveedores de alojamiento grandes tienen su propia estructura de subred, que proporciona un retraso m铆nimo para el usuario final.

Se decidi贸 escribir un servidor autoritario.


Redes con punto a punto (izquierda) y cliente-servidor (derecha)

Transferencia de datos entre cliente y servidor


Usamos Photon Server : esto nos permiti贸 implementar r谩pidamente la infraestructura necesaria para el proyecto sobre la base de un esquema que ya se desarroll贸 a lo largo de los a帽os (en War Robots lo usamos).

Photon Server es exclusivamente una soluci贸n de transporte para nosotros, sin dise帽os de alto nivel que est茅n fuertemente vinculados a un motor de juego espec铆fico. Lo que ofrece cierta ventaja, ya que la biblioteca de transferencia de datos se puede reemplazar en cualquier momento.

El servidor del juego es una aplicaci贸n multiproceso en el contenedor Photon. Se crea una secuencia separada para cada coincidencia, que encapsula toda la l贸gica de trabajo y evita la influencia de una coincidencia en otra. Photon controla todas las conexiones del servidor, y los datos que le llegan de los clientes se agregan a la cola, que luego se analiza en ECS.


Esquema general de transmisiones de coincidencias en el contenedor de Photon Server

Cada partido consta de varias etapas:

  1. El cliente del juego hace cola en el llamado servicio de emparejamiento. Tan pronto como se re煤ne el n煤mero requerido de jugadores que satisfacen ciertas condiciones, informa esto al servidor del juego usando gRPC. Al mismo tiempo, se transmiten todos los datos necesarios para crear el juego.


    Esquema general para crear una coincidencia.
  2. En el servidor del juego, comienza la inicializaci贸n del partido. Todos los par谩metros de coincidencia se procesan y preparan, incluidos los datos del mapa, as铆 como todos los datos del cliente recibidos del servicio de creaci贸n de coincidencias. Procesar y preparar datos implica que analizamos todos los datos necesarios y los escribimos en un subconjunto especial de entidades que llamamos RuleBook. Almacena las estad铆sticas de los partidos (que no cambian durante su curso) y se transmitir谩n a todos los clientes durante la conexi贸n y la autorizaci贸n en el servidor del juego una vez o cuando se vuelvan a conectar despu茅s de perder la conexi贸n. Los datos de coincidencia est谩ticos incluyen la configuraci贸n del mapa (presentaci贸n del mapa por componentes de ECS que los conectan al motor f铆sico), datos del cliente (apodos, un conjunto de armas que tienen y que no cambian durante la batalla, etc.).
  3. Corriendo un partido. Los sistemas ECS que componen el juego en el servidor comienzan a funcionar. Todos los sistemas est谩n marcando 30 cuadros por segundo.
  4. Cada cuadro lee y desempaqueta las entradas o copias del jugador si los jugadores no enviaron su entrada dentro de un cierto intervalo.
  5. Luego, en el mismo marco, la entrada se procesa en el sistema ECS, a saber: cambio de estado del jugador; el mundo que influye con su aportaci贸n; y el estado de otros jugadores.
  6. Al final de la trama, el estado mundial resultante se empaqueta para el jugador y se env铆a a trav茅s de la red.
  7. Al final de la partida, los resultados se env铆an a los clientes y al microservicio, que procesa las recompensas de la batalla usando gRPC, as铆 como al analista de la partida.
  8. Despu茅s de eso, el flujo de cerillas se cierra y el flujo se cierra.


La secuencia de acciones en el servidor dentro de un marco

En el lado del cliente, el proceso de conexi贸n a un partido es el siguiente:

  1. Primero, se hace una solicitud para hacer cola en el servicio para crear coincidencias a trav茅s de websocket con serializaci贸n a trav茅s de protobuf.
  2. Al crear una partida, este servicio informa al cliente de la direcci贸n del servidor del juego y transfiere la carga adicional requerida por el cliente antes de la partida. Ahora el cliente est谩 listo para iniciar el proceso de autorizaci贸n en el servidor del juego.
  3. El cliente crea un socket UDP y comienza a enviar una solicitud al servidor del juego para conectarse al partido junto con algunas credenciales. El servidor ya est谩 esperando a este cliente. Cuando est谩 conectado, le da todos los datos necesarios para comenzar el juego y mostrar el mundo por primera vez. Estos incluyen: RuleBook (una lista de datos est谩ticos para el partido), as铆 como StringIntMap, al que nos referimos como datos sobre las l铆neas utilizadas en el juego que ser谩n identificadas por los enteros durante el partido). Esto es necesario para ahorrar tr谩fico, porque pasando l铆neas cada cuadro crea una carga significativa en la red. Por ejemplo, todos los nombres de jugadores, nombres de clases, identificadores de armas, cuentas y similares, toda la informaci贸n se escribe en StringIntMap, donde se codifica utilizando datos enteros simples.

Cuando un jugador afecta directamente a otros usuarios (causa da帽os, impone efectos, etc.), se busca un historial de estado en el servidor para comparar el mundo del juego que el cliente realmente ve en una marca de simulaci贸n espec铆fica con lo que estaba sucediendo en el servidor con otros en ese momento. entidades del juego

Por ejemplo, disparas a tu cliente. Para usted, esto sucede instant谩neamente, pero el cliente ya se ha "escapado" por alg煤n tiempo en comparaci贸n con el mundo circundante, que muestra. Por lo tanto, debido a la predicci贸n local del comportamiento del jugador, el servidor necesita comprender d贸nde y en qu茅 estado estaban los oponentes en el momento del disparo (tal vez ya estaban muertos o, por el contrario, invulnerables). El servidor verifica todos los factores y presenta su veredicto sobre el da帽o hecho.


Solicitud para crear una partida, conectarse a un servidor de juegos y autorizaci贸n

Serializaci贸n y deserializaci贸n, empaquetado y desempaquetado de los primeros bytes del partido.


Tenemos una serializaci贸n de datos binarios patentada, y para la transferencia de datos usamos UDP.

UDP es la opci贸n m谩s obvia para enviar mensajes r谩pidamente entre el cliente y el servidor, donde generalmente es mucho m谩s importante mostrar los datos lo antes posible que, en principio, mostrarlos. Los paquetes perdidos hacen ajustes, pero los problemas se resuelven para cada caso individualmente, como Dado que los datos provienen constantemente del cliente al servidor y viceversa, puede ingresar el concepto de una conexi贸n entre el cliente y el servidor.

Para crear un c贸digo 贸ptimo y conveniente basado en la descripci贸n declarativa de la estructura de nuestro ECS, utilizamos la generaci贸n de c贸digo. Al crear componentes, tambi茅n se generan reglas de serializaci贸n y deserializaci贸n para ellos. La serializaci贸n se basa en un empaquetador binario personalizado que le permite empacar datos de la manera m谩s econ贸mica. El conjunto de bytes obtenido durante su funcionamiento no es el m谩s 贸ptimo, pero le permite crear una secuencia desde la que puede leer algunos datos de paquetes sin la necesidad de su deserializaci贸n completa.

El l铆mite de transferencia de datos de 1500 bytes (tambi茅n conocido como MTU) es, de hecho, el tama帽o m谩ximo de paquete que se puede transferir a trav茅s de Ethernet. Esta propiedad se puede configurar en cada salto de la red y, a menudo, incluso por debajo de 1500 bytes. 驴Qu茅 sucede si env铆o un paquete de m谩s de 1500 bytes? Comienza la fragmentaci贸n del paquete. Es decir cada paquete se dividir谩 a la fuerza en varios fragmentos, que se enviar谩n por separado de una interfaz a otra. Se pueden enviar por rutas completamente diferentes, y el tiempo para recibir dichos paquetes puede aumentar significativamente antes de que la capa de red emita un paquete pegado a su aplicaci贸n.

En el caso de Photon, la biblioteca comienza a enviar dichos paquetes en modo UDP confiable. Es decir El fot贸n esperar谩 cada fragmento del paquete, y reenviar谩 los fragmentos que faltan si se pierden durante el reenv铆o. Pero tal trabajo de la parte de la red es inaceptable en los juegos donde se requiere un retraso m铆nimo de la red. Por lo tanto, se recomienda reducir el tama帽o de los paquetes reenviados a un m铆nimo y no exceder los 1500 bytes recomendados (en nuestro juego, el tama帽o de un estado completo del mundo no excede los 1000 bytes; el tama帽o del paquete con compresi贸n delta es de 200 bytes).

Cada paquete del servidor tiene un encabezado corto que contiene varios bytes que describen el tipo de paquete. El cliente primero desempaqueta este conjunto de bytes y determina con qu茅 paquete estamos tratando. Confiamos en gran medida en esta propiedad de nuestro mecanismo de deserializaci贸n durante la autorizaci贸n: para no exceder el tama帽o de paquete recomendado de 1500 bytes, dividimos los paquetes RuleBook y StringIntMap en varias etapas; y para entender qu茅 obtuvimos exactamente del servidor, las reglas del juego o el estado en s铆, utilizamos el encabezado del paquete.

Cuando se desarrollan nuevas caracter铆sticas del proyecto, el tama帽o del paquete crece constantemente. Cuando nos encontramos con este problema, se decidi贸 escribir nuestro propio sistema de compresi贸n delta, as铆 como el recorte contextual de datos que el cliente no necesitaba.

Optimizaci贸n del tr谩fico de red sensible al contexto. Compresi贸n Delta


El recorte de datos contextuales se escribe manualmente en funci贸n de los datos que el cliente necesita para mostrar correctamente el mundo y la predicci贸n local de sus propios datos para funcionar correctamente. Luego, la compresi贸n delta se aplica a los datos restantes.

Nuestro juego cada tick produce un nuevo estado del mundo, que debe ser empaquetado y transmitido a los clientes. Por lo general, la compresi贸n delta es enviar primero un estado completo con todos los datos necesarios al cliente y luego enviar solo los cambios a estos datos. Esto se puede representar de la siguiente manera:

deltaGameState = newGameState - prevGameState

Pero para cada cliente se env铆an datos diferentes y la p茅rdida de un solo paquete puede llevar al hecho de que tiene que reenviar el estado completo del mundo.

Reenviar el estado completo del mundo es una tarea bastante costosa para la red. Por lo tanto, modificamos el enfoque y enviamos la diferencia entre el estado procesado actual del mundo y el que recibe exactamente el cliente. Para hacer esto, el cliente en su paquete con la entrada tambi茅n env铆a un n煤mero de marca, que es un identificador 煤nico del estado del juego que ya recibi贸 exactamente. Ahora el servidor sabe sobre la base de qu茅 estado es necesario construir la compresi贸n delta. El cliente generalmente no tiene tiempo para enviar al servidor el n煤mero de marca que tiene antes de que el servidor prepare el siguiente marco con los datos. Por lo tanto, en el cliente hay un historial de estados del servidor del mundo, al que se aplica el parche deltaGameState generado por el servidor.


Ilustraci贸n de la frecuencia de interacci贸n cliente-servidor en el proyecto.

Deteng谩monos en m谩s detalle sobre lo que env铆a el cliente. En los shooters cl谩sicos, dicho paquete se llama ClientCmd y contiene informaci贸n sobre las teclas presionadas por el jugador y el momento en que se cre贸 el equipo. Dentro del paquete de entrada, enviamos muchos m谩s datos:

public sealed class InputSample { //  ,        public uint WorldTick; // ,      ,     public uint PlayerSimulationTick; //   .  (idle, , ) public MovementMagnitude MovementMagnitude; //  ,   public float MovementAngle; //    public AimMagnitude AimMagnitude; //    public float AimAngle; //   ,       public uint ShotTarget; //    ,        public float AimMagnitudeCompressed; } 


Hay algunos puntos interesantes. En primer lugar, el cliente le dice al servidor en qu茅 marca ve todos los objetos del mundo del juego que lo rodea que no puede predecir (WorldTick). Puede parecer que el cliente puede "detener" el tiempo para el mundo, y correr y disparar a todos por la predicci贸n local. Esto no es asi. Confiamos solo en un conjunto limitado de valores del cliente y no le permitimos disparar al pasado por m谩s de 1 segundo. El campo WorldTick tambi茅n se usa como un paquete de reconocimiento, en base al cual se construye la compresi贸n delta.

Puede encontrar n煤meros de coma flotante en un paquete. Por lo general, estos valores a menudo se usan para tomar lecturas del joystick del jugador, pero no se transmiten muy bien a trav茅s de la red, ya que tienen un gran "rebote" y generalmente son demasiado precisos. Cuantificamos dichos n煤meros y los empaquetamos usando un empacador binario para que no excedan un valor entero que pueda caber en varios bits, dependiendo de su tama帽o. Por lo tanto, el paquete de entrada del joystick de punter铆a se rompe:

 if (Math.Abs(s.AimMagnitudeCompressed) < float.Epsilon) { packer.PackByte(0, 1); } else { packer.PackByte(1, 1); float min = 0; float max = 1; float step = 0.001f; //     1000    , //          //     packer.PackUInt32((uint)((s.AimMagnitudeCompressed - min)/step), CalcFloatRangeBits(min, max, step)); } 


Otra caracter铆stica interesante al enviar entradas es que algunos comandos se pueden enviar varias veces. Muy a menudo se nos pregunta qu茅 hacer si una persona ha presionado la habilidad m谩xima y se ha perdido el paquete con su entrada. Simplemente enviamos esta entrada varias veces. Parece una entrega garantizada, pero m谩s flexible y m谩s r谩pida. Porque el tama帽o del paquete de entrada es muy peque帽o, podemos empacar varias entradas de jugadores adyacentes en el paquete resultante. Por el momento, el tama帽o de la ventana que determina su n煤mero es cinco.


Paquetes de entrada generados en el cliente en cada tic y enviados al servidor

La transmisi贸n de este tipo de datos es lo suficientemente r谩pida y confiable como para resolver nuestros problemas sin usar un UDP confiable. Partimos del hecho de que la probabilidad de perder tal cantidad de paquetes seguidos es muy baja y es un indicador de una grave degradaci贸n de la calidad de la red en su conjunto. Si esto sucede, el servidor simplemente copia la 煤ltima entrada recibida del jugador y la aplica, con la esperanza de que no se modifique.

Si el cliente se da cuenta de que no recibi贸 paquetes a trav茅s de la red durante mucho tiempo, se inicia el proceso de reconexi贸n con el servidor. El servidor, por su parte, supervisa que la cola de entrada del reproductor est茅 completa.

En lugar de conclusi贸n y referencia


Hay muchos otros sistemas en el servidor del juego que son responsables de detectar, depurar y editar coincidencias "por ganancia", los dise帽adores de juegos actualizan la configuraci贸n sin reiniciar, registrar y monitorear el estado de los servidores. Tambi茅n queremos escribir sobre esto con m谩s detalle, pero por separado.

En primer lugar, al desarrollar un juego de red en plataformas m贸viles, debe prestar atenci贸n al funcionamiento correcto de su cliente con pings altos (aproximadamente 200 ms), p茅rdida de datos un poco m谩s frecuente, as铆 como el tama帽o de los datos enviados. Y debe ajustarse claramente al l铆mite de paquetes de 1500 bytes para evitar fragmentaci贸n y demoras en el tr谩fico.

Enlaces utiles:


Art铆culos anteriores sobre el proyecto:

  1. "C贸mo nos lanzamos en un tirador m贸vil de ritmo r谩pido: tecnolog铆a y enfoques" .
  2. "C贸mo y por qu茅 escribimos nuestro ECS" .
  3. "Como escribimos el c贸digo de red del tirador PvP m贸vil: sincronizaci贸n del jugador en el cliente" .

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


All Articles