Desarrollo de juegos en LibGDX utilizando la plantilla del sistema de componentes de la entidad

Hola habr Mi nombre es Andrey Shilo, soy un desarrollador de Android en FINCH . Hoy les contaré qué errores no deben cometerse al escribir incluso el juego más simple y por qué el enfoque arquitectónico del Sistema de componentes de entidad (ECS) es genial.

La primera vez siempre es dolorosa.


Tenemos un proyecto divertido para una gran empresa de medios, que es una red social no estándar. red con publicaciones, comentarios, me gusta y videos. Una vez, nos dieron una tarea: introducir la mecánica del juego como una promoción. El juego parecía simples mini carreras, donde con un toque en el lado izquierdo / derecho, el auto se movía hacia el carril izquierdo / derecho. Entonces, esquivando obstáculos y recolectando impulsores, tenías que llegar a la línea de meta, en general, el jugador tenía tres vidas.

Se suponía que el juego se implementaría dentro de la aplicación, por supuesto, en una pantalla separada. Elegimos incondicionalmente LibGDX como motor, ya que puede codificar el juego en kotiln y depurarlo en el escritorio, iniciando el juego como una aplicación java . Al mismo tiempo, no teníamos personas que conocieran otros motores que pudieran implementarse en la aplicación (si lo sabe, compártalos).

El juego se ve así:



Dado que el juego según el TK original parecía simple, no profundizamos en los enfoques arquitectónicos. Además, las promociones pasan rápidamente: en promedio, una acción demora un mes y medio. En consecuencia, más tarde, el código del juego simplemente se cortará y no será necesario hasta la próxima promoción, siempre que alguien quiera repetir algo así.

Todos los factores descritos anteriormente y los amados e instantes gerentes nos empujaron a escribir una mecánica de juego sin ninguna arquitectura.

Descripción del juego resultante.


La mayor parte del código se compiló en las clases: MyGdxGame: Game , GameScreen: Screen y TrafficGame: Actor .

MyGdxGame: es el punto de entrada al comienzo del juego, aquí los parámetros se transfieren al constructor en forma de cadenas. GameScreen y los parámetros del juego también se crean aquí, que se pasan a esta clase, pero en una forma diferente.

GameScreen: crea un actor del juego TrafficGame, lo agrega a la escena, le pasa los parámetros ya mencionados y también "escucha" los clics del usuario en la pantalla y llama a los métodos correspondientes del actor TrafficGame.

TrafficGame: el actor principal de la escena en la que tiene lugar todo el movimiento del juego: renderizado y lógica de trabajo.

Aunque el uso de scene2d hace posible construir árboles de anidación de actores, esta no es la mejor solución arquitectónica. Sin embargo, para implementar un juego UI / UX (en LibGDX), scene2d es una gran opción.

En nuestro caso, TrafficGame tiene una gran colección de instancias mixtas y todo tipo de indicadores de comportamiento que se permitieron en métodos con construcciones de gran tamaño. Un ejemplo:

var isGameActive: Boolean = true set(value) { backgroundActor?.isGameActive = value boostersMap.values.filterNotNull().forEach { it.isGameActive = value } obstaclesMap.values.filterNotNull().forEach { it.isGameActive = value } finishActor.isGameActive = value field = value } private var backgroundActoolbarActor private val pauseButtonActor: PauseButtonActor private val finishActor: FinishActor private var isQuizWon = falser: BackgroundActor? = null private var playerCarActor: PlayerCarActor private var toolbarActor: To private var pointsTime = 0f private var totalTimeElapsed = 0 private val timeToFinishTheGame = 50 private var lastQuizBoosterTime = 0.0f private var lastBoosterTime = 0.0f private val boostersMap = hashMapOf<Long?, BoosterActor?>() private var boosterSpawnedCount = 0 private var totalBoostersEatenCount = 0 private val boosterLimit = 20 private var lastBoosterYPos = 0.0f private var toGenerateBooster = false private var lastObstacleTime = 0.0f private var obstaclesMap = hashMapOf<Long?, ObstacleActor?>() 

Naturalmente, no deberías escribir así. Pero en defensa diré que sucedió así porque:

  • Necesita comenzar ahora, bueno, y le mostraremos el TK final con el diseño más adelante. Clásico
  • Las arquitecturas que ya son familiares (MVP / MVC / MVVM, etc.) no son adecuadas para la implementación del proceso del juego, ya que están diseñadas exclusivamente para la interfaz de usuario, en el juego todo sucede en tiempo real.
  • Inicialmente, el juego parecía simple, pero de hecho requería mucho código, teniendo en cuenta una gran cantidad de matices, la mayor parte de los cuales surgieron durante la escritura del juego.



Además de todas estas dificultades, hay otro problema común con la herencia. Si hace que un juego sea más difícil, por ejemplo, un juego de plataformas, entonces surge la pregunta: "¿Cómo distribuir el código reutilizable entre los objetos del juego?" La opción más comúnmente elegida es la herencia, donde el código reutilizable se coloca en las clases principales. Pero esta solución crea muchos problemas si aparecen condiciones que no encajan en el árbol de herencia:



Y generalmente resuelven estos problemas reescribiendo la estructura del árbol de herencia desde cero (bueno, esta vez será mejor), o las clases principales se rompen con muletas.



ECS es nuestro todo


Una historia completamente diferente es nuestro segundo juego promocional. Era como Flappy Bird , pero con diferencias: el personaje estaba controlado por la voz, y el techo y el piso no eran obstáculos, se podía deslizar sobre ellos.
Un ejemplo de juego y para comparar, el proceso de jugar Flappy Bird:




Para mayor claridad, en el ejemplo, la cámara se retira para ver el backstage del juego. El piso y el techo son bloques cuadrados que, llegando al borde, se reordenan al principio, y los obstáculos se generan de acuerdo con un patrón dado que proviene de la parte posterior. El diseño del juego fue elegido por los clientes, así que no te sorprendas.

Me gusta el desarrollo de juegos para dispositivos móviles y durante las horas libres, en aras de la experimentación, exploraré los patrones del juego y todo lo relacionado con el desarrollo del juego. Leí un libro sobre patrones de diseño de juegos , pero no entendí cuál debería ser la verdadera arquitectura de la lógica del juego, hasta que me encontré con ECS.

Sistema de componentes de la entidad: el patrón de diseño más utilizado en el desarrollo de juegos. La idea principal del patrón es la composición en lugar de la herencia . La composición le permite mezclar diferentes mecánicas en los objetos del juego, esto, en el futuro, le permite delegar la configuración de las propiedades del objeto a un diseñador de juegos, por ejemplo, a través de un constructor escrito. Como ya estaba familiarizado con este patrón, decidimos aplicarlo en el segundo juego.

Considere los componentes del patrón:

  • Componente : objetos con una estructura de datos simple que no contiene ninguna lógica, o que actúan como un atajo. Los componentes se dividen por propósito y determinan todas las propiedades de los objetos del juego. Velocidad, posición, textura, cuerpo, etc. etc. todo esto se describe en los componentes y luego se agrega a los objetos del juego.

     class VelocityComponent: Component { val velocity = Vector2() } 
  • Entidad - objetos del juego: obstáculos / refuerzos / héroe controlado e incluso fondo. No tienen clases especializadas por tipo: UltraMegaSuperman: GameUnit, sino que simplemente son contenedores para el conjunto de componentes. El hecho de que una determinada entidad sea UltraMegaSuperman define su conjunto de componentes y sus parámetros.
    Por ejemplo, en nuestro caso, el personaje principal tenía los siguientes componentes:

    • TextureComponent: define qué dibujar en la pantalla
    • TransformComponent: la posición del objeto en el espacio del juego
    • VelocityComponent: velocidad del objeto en el espacio del juego
    • HeroControllerComponent: contiene valores que afectan el movimiento del héroe
    • ImmortalityTimeComponent: contiene el tiempo restante de inmortalidad
    • Componente dinámico: indica que el objeto no es estático y está sujeto a la gravedad.
    • BodyComponent: define el cuerpo físico del héroe 2d necesario para calcular colisiones
  • Sistema : contiene el código para procesar datos de los componentes de cada entidad. No deben almacenar objetos de entidad y / o componente , ya que esto contradecirá el patrón. Idealmente, deberían estar limpios.

    Los sistemas hacen todo el trabajo sucio: dibujan todos los objetos del juego, mueven el objeto por su velocidad, verifican colisiones, cambian la velocidad del control entrante, etc. Por ejemplo, el efecto de la gravedad se ve así:

     override fun processEntity(entity: Entity, deltaTime: Float) { entity.getComponent(VelocityComponent::class.java) .velocity .add(0f, -GRAVITY * deltaTime) } 

    La especialización de cada sistema determina los requisitos para las entidades que debe procesar. Es decir, en el ejemplo anterior, la entidad debe tener los componentes de velocidad VelocityComponent y DynamicComponent para que esta entidad pueda procesarse; de ​​lo contrario, la entidad no es interesante para el sistema y, por lo tanto, con los demás. Para dibujar una textura, por ejemplo, debe saber qué es la textura TextureComponent y dónde dibujar TransformComponent. Para determinar los requisitos en cada sistema, la familia se escribe en el constructor en el que se indican las clases de componentes.

     Family.all(TransformComponent::class.java, TextureComponent::class.java).get() 

    Además, el orden de las entidades de procesamiento dentro del sistema puede ser ajustado por un comparador. Además, el orden de ejecución de los sistemas en el motor también se rige por el valor de prioridad.

El motor combina tres componentes. Contiene todos los sistemas y todas las entidades del juego. Al comienzo del juego, todos los sistemas necesarios en el juego.

 engine.apply { addSystem(ControlSystem()) addSystem(GravitySystem()) addSystem(renderingSystem) addSystem(MovementSystem()) addSystem(EnemyGeneratorSystem()) } 
así como las entidades iniciales se agregan al motor,
 val hero: Entity = engine.createEntity() engine.addEntity(hero) 

PooledEngine :: createEntity : obtiene un objeto de entidad del grupo , ya que las entidades se pueden crear durante el juego para no ensuciar la memoria. Si es necesario, se toman del grupo de objetos y, cuando se eliminan, se vuelven a colocar. Del mismo modo hecho para componentes. Al recibir componentes del grupo, es necesario inicializar todos los campos, ya que pueden contener información sobre el uso anterior de este componente.

La relación entre las partes principales del patrón se presenta a continuación:



El motor contiene una colección de sistemas y una colección de entidades. Cada sistema recibe un enlace del motor a una colección de entidades, que es una selección de la colección general de acuerdo con los requisitos del sistema; se actualizará durante el juego cuando las entidades y los componentes cambien. Cada entidad contiene una colección de sus componentes que la definen en el juego.

El ciclo del juego está estructurado de la siguiente manera:

  1. Usando la implementación del patrón "Ciclo de juego" de LibGDX, en el método de actualización obtenemos su incremento en cada paso de tiempo: deltaTime.
  2. A continuación, pasamos el tiempo al motor. Y él, a su vez, itera a través del sistema en un ciclo, los distribuye deltaTime.
     for (i in 0 until systems.size()) { val system = systems[i] if (system.checkProcessing()) { system.update(deltaTime) } } 
  3. Los sistemas que reciben deltaTime clasifican sus entidades y les aplican cambios teniendo en cuenta deltaTime.
     for (i in 0 until entities.size()) { processEntity(entities[i], deltaTime) } 

Esto sucede cada medida del juego.

Beneficios de ECS


  1. Los datos son lo primero . Dado que los sistemas procesan solo aquellas entidades que les convienen, en ausencia de tales entidades, el sistema simplemente no hará nada, esto hace posible probar y depurar nuevas características, creando solo las entidades necesarias para esto.

    Por ejemplo, creaste el juego "Tanks". Después de un tiempo, decidió agregar un nuevo tipo de terreno: "lava". Si el tanque intenta atravesarlo, terminará en falla. Pero la tecnología futurista viene al rescate, instalando la cual puedes cruzar la lava.

    Para depurar este caso, no es necesario crear modelos completos de tanques y construir mapas completos con ubicaciones de lava agregadas, solo piense en los componentes mínimos necesarios en el tanque y agregue una entidad al motor del juego para probar. Todo esto suena obvio, pero de hecho te encuentras con la clase TheTank que en el constructor pide una lista de parámetros: calibre, velocidad, sprite, velocidad de disparo, nombres de la tripulación, etc. aunque esto es innecesario para probar las intersecciones de lava.
  2. Además, siguiendo el ejemplo del párrafo anterior, notamos una gran flexibilidad , ya que con este enfoque es mucho más fácil agregar y eliminar funciones.

    Un verdadero ejemplo. El escenario del juego de nuestra segunda promoción fue que el usuario, después de ejecutar una canción, se estrelló en la línea de meta en ~ 2 minutos, y el juego comenzó la cuenta regresiva reiniciando el nivel nuevamente, pero ahorrando puntos, dando así al jugador un descanso.

    Un par de días antes del lanzamiento esperado llega la tarea de "eliminar la línea de meta y hacer un informe de media hora de juego continuo con obstáculos y canciones en bucle". Un cambio global, pero fue muy fácil de hacer: fue suficiente para eliminar la esencia de la línea de meta y agregar un sistema de referencia para el final del juego.
  3. Todos los desarrollos son fáciles de probar . Sabiendo que los datos en primer lugar, puede simular cualquier caso de prueba, ejecutarlos y ver el resultado.
  4. En el futuro, para validar el estado del juego , puede conectar un servidor al proceso del juego. Ejecutará a través del mismo código la misma entrada del cliente y comparará su resultado con el resultado en el cliente. Si los datos no convergen, entonces el cliente es un tramposo o tenemos errores en el juego.


ECS en el gran mundo de gamedev


Las grandes empresas como Unity, Epic o Crytek utilizan esta plantilla en sus marcos para proporcionar a los desarrolladores una herramienta con muchas características. Te aconsejo que veas el informe sobre cómo se implementó la lógica del juego en Overwatch

Para una mejor comprensión, hice un pequeño ejemplo en github .
Gracias por su atencion!

También sobre el tema:


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


All Articles