Unlimited WordPress themes, graphics, videos & courses! Unlimited asset downloads! From $16.50/m
Advertisement
  1. Game Development
  2. Programming

Evitar la Blob Antipattern: un enfoque pragmático de la composición de la entidad

by
Read Time:16 minsLanguages:

Spanish (Español) translation by Elías Nicolás (you can also view the original English article)

Organizar el código del juego en entidades basadas en componentes, en lugar de confiar sólo en la herencia de clase, es un enfoque popular en el desarrollo de juegos. En este tutorial, veremos por qué podría hacer esto, y configurar un motor de juego simple utilizando esta técnica.


Introducción

En este tutorial voy a explorar las entidades de juego basadas en componentes, mirar por qué es posible que desee utilizarlas, y sugerir un enfoque pragmático para mojar su dedo en el agua.

Como se trata de una historia sobre la organización del código y la arquitectura, voy a empezar por dejar caer en la costumbre "salir de la cárcel" renuncia: esta es sólo una forma de hacer las cosas, no es "la única manera" o tal vez incluso la mejor manera, Pero podría funcionar para usted. Personalmente, me gusta averiguar acerca de tantos enfoques como sea posible y luego elaborar lo que me conviene.


Vista previa del resultado final


A lo largo de este tutorial en dos partes, crearemos este juego de asteroides. (El código fuente completo está disponible en GitHub). En esta primera parte, nos enfocaremos en los conceptos básicos y en el motor general del juego.


¿Qué problema estamos resolviendo?

En un juego como Asteroids, podríamos tener algunos tipos básicos de "cosa" en pantalla: balas, asteroides, buque jugador y barco enemigo. Podríamos representar estos tipos básicos como cuatro clases separadas, cada una conteniendo todo el código que necesitamos para dibujar, animar, mover y controlar ese objeto.

Si bien esto funcionará, podría ser mejor seguir el principio de no te repitas (NTR) y tratar de reutilizar parte del código entre cada clase - después de todo, el código para mover y dibujar una bala va a ser muy Similar, si no exactamente igual, al código para mover y dibujar un asteroide o un barco.

Así podemos refactorizar nuestras funciones de representación y movimiento en una clase base de la que todo se extiende. Pero Ship y EnemyShip también necesitan ser capaces de disparar. En este punto podríamos agregar la función de disparo shoot a la clase base, creando una clase "Bloque gigante" que puede hacer básicamente todo, y sólo asegúrese de que los asteroides y las balas nunca llaman a su función de disparo shoot. Esta clase básica pronto llegaría a ser muy grande, aumentando de tamaño cada vez que las entidades necesitan ser capaces de hacer cosas nuevas. Esto no es necesariamente malo, pero creo que las clases más pequeñas y más especializadas son más fáciles de mantener.

Alternativamente, podemos bajar la raíz de la herencia profunda y tener algo como EnemyShip extends Ship extends ShootingEntity extends Entity. Una vez más, este enfoque no es incorrecto, y también funcionará bastante bien, pero a medida que añada más tipos de Entidades, se encontrará constantemente teniendo que reajustar la jerarquía de herencia para manejar todos los posibles escenarios, y puede encajonarse en una esquina Donde un nuevo tipo de Entidad necesita tener la funcionalidad de dos clases base diferentes, requiriendo herencia múltiple (que la mayoría de los lenguajes de programación no ofrecen).

He utilizado el enfoque de jerarquía profunda muchas veces, pero realmente prefiero el enfoque de Bloque Gigante, al menos entonces todas las entidades tienen una interfaz común y nuevas entidades se pueden agregar más fácilmente (¡¿y qué si todos sus árboles tienen A * pathfinding?!)

Hay, sin embargo, una tercera forma...


Composición sobre la herencia

Si pensamos en el problema de los asteroides en términos de cosas que los objetos podrían necesitar hacer, podríamos obtener una lista como esta:

  • move()
  • shoot()
  • takeDamage()
  • die()
  • render()

En lugar de elaborar una jerarquía de herencia complicada para la que los objetos pueden hacer qué cosas, modelemos el problema en términos de componentes que pueden realizar estas acciones.

Por ejemplo, podríamos crear una clase Health, con los métodos takeDamage(), heal() y die(). Entonces cualquier objeto que necesite ser capaz de recibir daño y morir puede "componer" una instancia de la clase Health - donde "componer" significa básicamente "mantener una referencia a su propia instancia de esta clase".

Podríamos crear otra clase llamada View para cuidar la funcionalidad de renderizado, una llamada Body para manejar el movimiento y otra llamada Weapon para manejar el disparo.

La mayoría de los sistemas de Entidad se basan en el principio descrito anteriormente, pero difieren en cómo se accede a la funcionalidad contenida en un componente.

Reflejar la API

Por ejemplo, un enfoque es reflejar el API de cada componente en la Entidad, por lo que una entidad que puede recibir daño tendría una función takeDamage() que por sí solo llama a la función takeDamage() de su componente Health.

A continuación, debe crear una interfaz denominada IHealth para que su entidad pueda implementar, de modo que otros objetos puedan tener acceso a la función takeDamage(). Así es como una guía Java OOP podría aconsejarle que lo haga.

getComponent()

Otra aproximación es simplemente almacenar cada componente en una búsqueda de valor-clave, de modo que cada Entidad tenga una función llamada algo como getComponent("componentName") que devuelve una referencia al componente en particular. A continuación, debe emitir la referencia que volver al tipo de componente que desea - algo así como:

Esto es básicamente cómo funciona el sistema de entidad / comportamiento de Unity. Es muy flexible, ya que puede seguir agregando nuevos tipos de componentes sin cambiar su clase base o crear nuevas subclases o interfaces. También podría ser útil cuando desee utilizar archivos de configuración para crear entidades sin recompilar el código, pero lo dejo a alguien para que lo averigüe.

Componentes Públicos

El enfoque que prefiero es dejar que todas las entidades tengan una propiedad pública para cada tipo principal de componente, y dejar los campos nulos si la entidad no tiene esa funcionalidad. Cuando se desea llamar a un método en particular, simplemente "alcanza" a la entidad para obtener el componente con esa funcionalidad; por ejemplo, llama a enemy.health.takeDamage(5) para atacar a un enemigo.

Si intenta llamar a health.takeDamage() a una entidad que no tiene un componente Health, se compilará, pero obtendrá un error de ejecución que le permitirá saber que ha hecho algo tonto. En la práctica esto rara vez sucede, ya que es bastante obvio qué tipos de entidad tendrá qué componentes (por ejemplo, por supuesto, un árbol no tiene un arma!).

Algunos defensores estrictos de OOP podrían argumentar que mi enfoque rompe algunos principios de OOP, pero creo que funciona muy bien, y hay un muy buen precedente de la historia de Adobe Flash.

En ActionScript 2, la clase MovieClip tenía métodos para dibujar gráficos vectoriales: por ejemplo, podría llamar a myMovieClip.lineTo() para dibujar una línea. En ActionScript 3, estos métodos de dibujo se movieron a la clase Graphics y cada MovieClip obtiene un componente Graphics al que se accede llamando, por ejemplo, myMovieClip.graphics.lineTo() de la misma manera que describí para enemy.health.takeDamage(). Si es lo suficientemente bueno para los diseñadores de lenguaje de ActionScript, es lo suficientemente bueno para mí.


Mi sistema (simplificado)

A continuación voy a detallar una versión muy simplificada del sistema que utilizo en todos mis juegos. En términos de cómo simplificado, es algo así como 300 líneas de código para esto, en comparación con 6.000 para mi motor completo. ¡Pero realmente podemos hacer bastante con sólo estas 300 líneas!

He dejado en suficiente funcionalidad para crear un juego de trabajo, manteniendo el código lo más corto posible para que sea más fácil de seguir. El código va a estar en ActionScript 3, pero una estructura similar es posible en la mayoría de los idiomas. Hay algunas variables públicas que podrían ser propiedades (es decir, poner detrás de get y set funciones de acceso), pero como esto es bastante detallado en ActionScript, los he dejado como variables públicas para facilitar la lectura.

La Interfaz IEntity

Comencemos definiendo una interfaz que todas las entidades implementarán:

Todas las entidades pueden realizar tres acciones: puede actualizarlas, procesarlas y destruirlas.

Cada uno tiene "ranuras" para cinco componentes:

  • Un body, manejando posición y tamaño.
  • physics, manejando el movimiento.
  • health, maneja ser herido.
  • Un weapon, manejando atacar.
  • Y, finalmente, una view, lo que le permite hacer la entidad.

Todos estos componentes son opcionales y pueden dejarse nulos, pero en la práctica la mayoría de las entidades tendrán al menos un par de componentes.

Un pedazo de paisaje estático con el que el jugador no puede interactuar (tal vez un árbol, por ejemplo), necesitaría sólo un cuerpo y una vista. No necesitaría la física porque no se mueve, no necesitaría la salud como usted no puede atacarla, y ciertamente no necesitaría un arma. El barco del jugador en Asteroids, por otro lado, necesitaría los cinco componentes, ya que puede moverse, disparar y herirse.

Al configurar estos cinco componentes básicos, puede crear objetos más sencillos que pueda necesitar. Sin embargo, a veces no serán suficientes, y en ese momento podemos ampliar los componentes básicos, o crear otros nuevos, los cuales discutiremos más adelante.

A continuación tenemos dos Señales: entityCreated y destroyed.

Signals son una alternativa de código abierto a los eventos nativos de ActionScript, creados por Robert Penner. Son muy agradables de usar, ya que permiten pasar datos entre el despachador y el oyente sin tener que crear muchas clases de eventos personalizadas. Para obtener más información sobre cómo utilizarlos, consulte la documentación.

La señal entityCreated permite que una entidad diga al juego que hay otra entidad nueva que necesita ser agregada - un ejemplo clásico es cuando una pistola crea una viñeta. La señal destroyed permite que el juego (y cualquier otro objeto de escucha) sepa que esta entidad ha sido destruida.

Finalmente, la entidad tiene otras dos dependencias opcionales: targets, que es una lista de entidades que podría querer atacar, y group, que es una lista de entidades a las que pertenece. Por ejemplo, un barco jugador puede tener una lista de objetivos, que serían todos los enemigos en el juego, y podría pertenecer a un grupo que también contiene otros jugadores y unidades amistosas.

La Clase de Entity

Ahora veamos la clase Entity que implementa esta interfaz.

Parece largo, pero la mayor parte de él es justo esas funciones verbose del getter y del setter (boo!). La parte importante a considerar es las primeras cuatro funciones: el constructor, donde creamos nuestras Señales; destroy(), donde enviamos la señal destruida y eliminamos la entidad de su lista de grupos; update(), donde actualizamos todos los componentes que necesitan actuar cada bucle de juego - aunque en este ejemplo simple esto es sólo el componente physics - y finalmente render(), donde decimos a la vista que haga su cosa.

Observará que no instanciamos automáticamente los componentes aquí en la clase Entity, ya que, como he explicado anteriormente, cada componente es opcional.

Los componentes individuales

Ahora veamos los componentes uno por uno. En primer lugar, el componente del cuerpo:

Todos nuestros componentes necesitan una referencia a su entidad propietaria, que pasamos al constructor. El cuerpo tiene entonces cuatro campos simples: una posición xey, un ángulo de rotación y un radio para almacenar su tamaño. (¡En este ejemplo simple, todas las entidades son circulares!)

Este componente también tiene un único método: testCollision(), que utiliza Pythagoras para calcular la distancia entre dos entidades, y lo compara con sus radios combinados. (Más información aquí.)

A continuación, echemos un vistazo al componente Physics:

Observando la función update(), se puede ver que los valores de velocityX y velocityY se añaden a la posición de la entidad, que la mueve, y la velocidad se multiplica por drag, lo que tiene el efecto de disminuir gradualmente el objeto hacia abajo. La función de thrust() permite una manera rápida de acelerar la entidad en la dirección que está mirando.

A continuación, echemos un vistazo al componente de Health:

El componente Health tiene una función llamada hit(), que permite que la entidad se vea herida. Cuando esto sucede, el valor de los hits se reduce y cualquier objeto de audición se notifica enviando la señal hurt. Si los hits son inferiores a cero, la entidad está muerta y enviamos la señal died.

Veamos qué hay dentro del componente Weapon:

No mucho aquí! Eso es porque esto es realmente sólo una clase base para las armas reales - como verás en el ejemplo Gun más tarde. Hay un método fire() que las subclases deben anular, pero aquí sólo reduce el valor de la ammo.

El componente final a examinar es View:

Este componente es muy específico para Flash. El evento principal aquí es la función render(), que actualiza un sprite Flash con la posición del cuerpo y los valores de rotación, y los valores alfa y de escala que se almacena. Si desea utilizar un sistema de renderizado diferente, como copyPixels blitting o Stage3D (o incluso un sistema relevante para una diferente opción de plataforma), se adaptaría esta clase.

La clase Game

Ahora sabemos lo que es una Entidad y todos sus componentes. Antes de empezar a usar este motor para hacer un ejemplo de juego, echemos un vistazo a la pieza final del motor: la clase Game que controla todo el sistema:

Hay un montón de detalles de implementación aquí, pero vamos a seleccionar los aspectos más destacados.

Cada trama, la clase Game pasa por todas las entidades y llama a sus métodos de actualización y renderización. En la función addEntity, añadimos la nueva entidad a la lista de entidades, escuchamos sus señales y, si tiene una vista, agregamos su sprite a la etapa.

Cuando onEntityDestroyed se activa, eliminamos la entidad de la lista y eliminamos su sprite de la etapa. En la función stopGame, que sólo llamas si quieres terminar el juego, eliminamos los sprites de todas las entidades del escenario y borramos la lista de entidades estableciendo su longitud en cero.


La próxima vez...

Wow, lo hicimos! ¡Ése es el motor del juego entero! Desde este punto de partida, podríamos hacer muchos sencillos juegos de arcade 2D sin mucho código adicional. En el próximo tutorial, usaremos este motor para hacer un espacio de estilo asteroides disparar-'em-up.

Advertisement
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.