1. Code
  2. Game Development

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

Scroll to top

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.

1
class Entity {
2
    private var _health:Health;
3
    //...other code...//

4
    public function takeDamage(dmg:int) {
5
        _health.takeDamage(dmg);
6
    }
7
}

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:

1
var health:Health = Health(getComponent("Health"));

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:

1
package engine
2
{
3
  import org.osflash.signals.Signal;
4
	/**

5
	 * ...

6
	 * @author Iain Lobb - iainlobb@gmail.com

7
	 */
8
	public interface IEntity
9
	{
10
		// ACTIONS

11
		function destroy():void;
12
		function update():void;
13
		function render():void;
14
15
		// COMPONENTS

16
		function get body():Body;
17
		function set body(value:Body):void;
18
		function get physics():Physics;
19
		function set physics(value:Physics):void
20
		function get health():Health
21
		function set health(value:Health):void
22
		function get weapon():Weapon;
23
		function set weapon(value:Weapon):void;
24
		function get view():View;
25
		function set view(value:View):void;
26
27
		// SIGNALS

28
		function get entityCreated():Signal;
29
		function set entityCreated(value:Signal):void;
30
		function get destroyed():Signal;
31
		function set destroyed(value:Signal):void;
32
33
		// DEPENDENCIES

34
		function get targets():Vector.<Entity>;
35
		function set targets(value:Vector.<Entity>):void;
36
		function get group():Vector.<Entity>;
37
		function set group(value:Vector.<Entity>):void;
38
	}
39
40
}

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.

1
package engine  
2
{
3
	import org.osflash.signals.Signal;
4
	
5
	/**

6
	 * ...

7
	 * @author Iain Lobb - iainlobb@gmail.com

8
	 */
9
	
10
	public class Entity implements IEntity
11
	{
12
		private var _body:Body;
13
		private var _physics:Physics;
14
		private var _health:Health;
15
		private var _weapon:Weapon;
16
		private var _view:View;
17
		private var _entityCreated:Signal;
18
		private var _destroyed:Signal;
19
		private var _targets:Vector.<Entity>;
20
		private var _group:Vector.<Entity>;
21
22
		/*

23
		 * Anything that exists within your game is an Entity!

24
		 */
25
		
26
		public function Entity() 
27
		{
28
			entityCreated = new Signal(Entity);
29
			destroyed = new Signal(Entity);
30
		}
31
		
32
		public function destroy():void
33
		{
34
			destroyed.dispatch(this);
35
			
36
			if (group) group.splice(group.indexOf(this), 1);
37
		}
38
		
39
		public function update():void
40
		{
41
			if (physics) physics.update();
42
		}
43
		
44
		public function render():void
45
		{
46
			if (view) view.render();
47
		}
48
		
49
		public function get body():Body 
50
		{
51
			return _body;
52
		}
53
		
54
		public function set body(value:Body):void 
55
		{
56
			_body = value;
57
		}
58
		
59
		public function get physics():Physics 
60
		{
61
			return _physics;
62
		}
63
		
64
		public function set physics(value:Physics):void 
65
		{
66
			_physics = value;
67
		}
68
		
69
		public function get health():Health 
70
		{
71
			return _health;
72
		}
73
		
74
		public function set health(value:Health):void 
75
		{
76
			_health = value;
77
		}
78
		
79
		public function get weapon():Weapon 
80
		{
81
			return _weapon;
82
		}
83
		
84
		public function set weapon(value:Weapon):void 
85
		{
86
			_weapon = value;
87
		}
88
		
89
		public function get view():View 
90
		{
91
			return _view;
92
		}
93
		
94
		public function set view(value:View):void 
95
		{
96
			_view = value;
97
		}
98
		
99
		public function get entityCreated():Signal 
100
		{
101
			return _entityCreated;
102
		}
103
		
104
		public function set entityCreated(value:Signal):void 
105
		{
106
			_entityCreated = value;
107
		}
108
		
109
		public function get destroyed():Signal 
110
		{
111
			return _destroyed;
112
		}
113
		
114
		public function set destroyed(value:Signal):void 
115
		{
116
			_destroyed = value;
117
		}
118
		
119
		public function get targets():Vector.<Entity> 
120
		{
121
			return _targets;
122
		}
123
		
124
		public function set targets(value:Vector.<Entity>):void 
125
		{
126
			_targets = value;
127
		}
128
		
129
		public function get group():Vector.<Entity> 
130
		{
131
			return _group;
132
		}
133
		
134
		public function set group(value:Vector.<Entity>):void 
135
		{
136
			_group = value;
137
		}
138
	}
139
}

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:

1
package engine
2
{
3
	/**

4
	 * ...

5
	 * @author Iain Lobb - iainlobb@gmail.com

6
	 */
7
	
8
	public class Body
9
	{
10
		public var entity:Entity;
11
		public var x:Number = 0;
12
		public var y:Number = 0;
13
		public var angle:Number = 0;
14
		public var radius:Number = 10;
15
		
16
		/*

17
		* If you give an entity a body it can take physical form in the world, 

18
		* although to see it you will need a view.

19
		*/
20
		
21
		public function Body(entity:Entity) 
22
		{
23
			this.entity = entity;
24
		}
25
		
26
		public function testCollision(otherEntity:Entity):Boolean
27
		{
28
			var dx:Number;
29
			var dy:Number;
30
			
31
			dx = x - otherEntity.body.x;
32
			dy = y - otherEntity.body.y;
33
			
34
			return Math.sqrt((dx * dx) + (dy * dy)) <= radius + otherEntity.body.radius; 
35
		}
36
	}
37
}

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:

1
package engine  
2
{
3
	/**

4
	 * ...

5
	 * @author Iain Lobb - iainlobb@gmail.com

6
	 */
7
	public class Physics
8
	{
9
		public var entity:Entity;
10
		public var drag:Number = 1;
11
		public var velocityX:Number = 0;
12
		public var velocityY:Number = 0;
13
		
14
		/*

15
		 * Provides a basic physics step without collision detection. 

16
		 * Extend to add collision handling.

17
		 */
18
		
19
		public function Physics(entity:Entity) 
20
		{
21
			this.entity = entity;
22
		}
23
		
24
		public function update():void
25
		{
26
			entity.body.x += velocityX;
27
			entity.body.y += velocityY;
28
			
29
			velocityX *= drag;
30
			velocityY *= drag;
31
		}
32
		
33
		public function thrust(power:Number):void
34
		{
35
			velocityX += Math.sin(-entity.body.angle) * power;
36
			velocityY += Math.cos(-entity.body.angle) * power;
37
		}
38
	}
39
40
}

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:

1
package engine  
2
{
3
	import org.osflash.signals.Signal;
4
	/**

5
	 * ...

6
	 * @author Iain Lobb - iainlobb@gmail.com

7
	 */
8
	public class Health
9
	{
10
		public var entity:Entity;
11
		public var hits:int;
12
		public var died:Signal;
13
		public var hurt:Signal;
14
		
15
		public function Health(entity:Entity) 
16
		{
17
			this.entity = entity;
18
			died = new Signal(Entity);
19
			hurt = new Signal(Entity);
20
		}
21
		
22
		public function hit(damage:int):void
23
		{
24
			hits -= damage;
25
			
26
			hurt.dispatch(entity);
27
			
28
			if (hits < 0)
29
			{
30
				died.dispatch(entity);
31
			}
32
		}
33
		
34
	}
35
36
}

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:

1
package engine 
2
{
3
	import org.osflash.signals.Signal;
4
	/**

5
	 * ...

6
	 * @author Iain Lobb - iainlobb@gmail.com

7
	 */
8
	public class Weapon
9
	{
10
		public var entity:Entity;
11
		public var ammo:int;
12
			
13
		/*

14
		 * Weapon is the base class for all weapons.

15
		 */
16
		
17
		public function Weapon(entity:Entity) 
18
		{
19
			this.entity = entity;
20
		}
21
		
22
		public function fire():void
23
		{
24
			ammo--;
25
		}
26
	}
27
}

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:

1
package engine  
2
{
3
	import flash.display.Sprite;
4
	/**

5
	 * ...

6
	 * @author Iain Lobb - iainlobb@gmail.com

7
	 */
8
	public class View
9
	{
10
		public var entity:Entity;
11
		public var scale:Number = 1;
12
		public var alpha:Number = 1;
13
		public var sprite:Sprite;
14
		
15
		/*

16
		 * View is display component which renders an Entity using the standard display list. 

17
		 */
18
		
19
		public function View(entity:Entity) 
20
		{
21
			this.entity = entity;
22
		}
23
		
24
		public function render():void
25
		{
26
			sprite.x = entity.body.x;
27
			sprite.y = entity.body.y;
28
			sprite.rotation = entity.body.angle * (180 / Math.PI);
29
			sprite.alpha = alpha;
30
			sprite.scaleX = scale;
31
			sprite.scaleY = scale;
32
		}
33
	}
34
}

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:

1
package engine 
2
{
3
	import flash.display.Sprite;
4
	import flash.display.Stage;
5
	import flash.events.Event;
6
	/**

7
	 * ...

8
	 * @author Iain Lobb - iainlobb@gmail.com

9
	 */
10
	
11
	 public class Game extends Sprite
12
	{
13
		public var entities:Vector.<Entity> = new Vector.<Entity>();
14
		public var isPaused:Boolean;
15
		static public var stage:Stage;
16
		
17
		/*

18
		 * Game is the base class for games.

19
		 */
20
		
21
		public function Game() 
22
		{
23
			addEventListener(Event.ENTER_FRAME, onEnterFrame);
24
			addEventListener(Event.ADDED_TO_STAGE, onAddedToStage);
25
		}
26
		
27
		protected function onEnterFrame(event:Event):void
28
		{
29
			if (isPaused) return;
30
			
31
			update();
32
			
33
			render();
34
		}
35
		
36
		protected function update():void
37
		{
38
			for each (var entity:Entity in entities) entity.update();
39
		}
40
		
41
		protected function render():void
42
		{
43
			for each (var entity:Entity in entities) entity.render();
44
		}
45
		
46
		protected function onAddedToStage(event:Event):void
47
		{
48
			Game.stage = stage;
49
			
50
			startGame();
51
		}
52
		
53
		protected function startGame():void
54
		{
55
			
56
		}
57
		
58
		protected function stopGame():void
59
		{
60
			for each (var entity:Entity in entities)
61
			{
62
				if (entity.view) removeChild(entity.view.sprite);
63
			}
64
			
65
			entities.length = 0;
66
		}
67
		
68
		public function addEntity(entity:Entity):Entity
69
		{
70
			entities.push(entity);
71
			
72
			entity.destroyed.add(onEntityDestroyed);
73
			entity.entityCreated.add(addEntity);
74
			
75
			if (entity.view) addChild(entity.view.sprite);
76
			
77
			return entity;
78
		}
79
		
80
		protected function onEntityDestroyed(entity:Entity):void
81
		{
82
			entities.splice(entities.indexOf(entity), 1);
83
			
84
			if (entity.view) removeChild(entity.view.sprite);
85
			
86
			entity.destroyed.remove(onEntityDestroyed);
87
		}
88
		
89
		
90
	}
91
}

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.