Crear un juego simple de asteroides usando entidades basadas en componentes
() translation by (you can also view the original English article)
En el tutorial anterior, hemos creado un sistema basado en componentes basados en componentes. Ahora usaremos este sistema para crear un simple juego de asteroides.
Vista previa del resultado final
Este es el sencillo juego de asteroides que vamos a crear en este tutorial. Está escrito con Flash y AS3, pero los conceptos generales se aplican a la mayoría de los idiomas.
El código fuente completo está disponible en GitHub.
Descripción de la Clase
Hay seis clases:
-
AsteroidsGame
, que extiende la clase de juego base y agrega la lógica específica a nuestro espacio dispararles. -
Ship
, que es lo que controlas. -
Asteroid
, que es la cosa en la que disparas. -
Bullet
, que es lo que disparas. -
Gun
, que crea esas balas. -
EnemyShip
, que es un extranjero errante que está ahí para añadir un poco de variedad al juego.
Vamos a pasar por estos tipos de entidad, uno por uno.
La clase Ship
Comenzaremos con la nave del jugador:
1 |
package asteroids |
2 |
{
|
3 |
import com.iainlobb.gamepad.Gamepad; |
4 |
import com.iainlobb.gamepad.KeyCode; |
5 |
import engine.Body; |
6 |
import engine.Entity; |
7 |
import engine.Game; |
8 |
import engine.Health; |
9 |
import engine.Physics; |
10 |
import engine.View; |
11 |
import flash.display.GraphicsPathWinding; |
12 |
import flash.display.Sprite; |
13 |
/**
|
14 |
* ...
|
15 |
* @author Iain Lobb - iainlobb@gmail.com
|
16 |
*/
|
17 |
public class Ship extends Entity |
18 |
{
|
19 |
protected var gamepad:Gamepad; |
20 |
|
21 |
public function Ship() |
22 |
{
|
23 |
body = new Body(this); |
24 |
body.x = 400; |
25 |
body.y = 300; |
26 |
|
27 |
physics = new Physics(this); |
28 |
physics.drag = 0.9; |
29 |
|
30 |
view = new View(this); |
31 |
view.sprite = new Sprite(); |
32 |
view.sprite.graphics.lineStyle(1.5, 0xFFFFFF); |
33 |
view.sprite.graphics.drawPath(Vector.<int>([1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]), |
34 |
Vector.<Number>([ -7.3, 10.3, -5.5, 10.3, -7, 0.6, -0.5, -2.8, 6.2, 0.3, 4.5, 10.3, 6.3, 10.3, 11.1, -1.4, -0.2, -9.6, -11.9, -1.3, -7.3, 10.3]), |
35 |
GraphicsPathWinding.NON_ZERO); |
36 |
|
37 |
health = new Health(this); |
38 |
health.hits = 5; |
39 |
health.died.add(onDied); |
40 |
|
41 |
weapon = new Gun(this); |
42 |
|
43 |
gamepad = new Gamepad(Game.stage, false); |
44 |
gamepad.fire1.mapKey(KeyCode.SPACEBAR); |
45 |
}
|
46 |
|
47 |
override public function update():void |
48 |
{
|
49 |
super.update(); |
50 |
|
51 |
body.angle += gamepad.x * 0.1; |
52 |
|
53 |
physics.thrust(-gamepad.y); |
54 |
|
55 |
if (gamepad.fire1.isPressed) weapon.fire(); |
56 |
}
|
57 |
|
58 |
protected function onDied(entity:Entity):void |
59 |
{
|
60 |
destroy(); |
61 |
}
|
62 |
}
|
63 |
|
64 |
}
|
Hay
un poco de detalles de implementación aquí, pero lo más importante es
notar que en el constructor instanciamos y configuramos los componentes
Body
, Physics
, Health
, View
y Weapon
. (El componente Weapon
es de hecho una instancia de Gun
en lugar de la
clase base de armas.)
Estoy usando las API de dibujo de gráficos Flash para crear mi nave (líneas 29-32), pero podríamos utilizar con igual facilidad una imagen de mapa de bits. También estoy creando una instancia de mi clase Gamepad - esta es una biblioteca de código abierto que escribí hace un par de años para facilitar la entrada de teclado en Flash.
También
he reemplazado la función update
de la clase base para añadir
un comportamiento personalizado: después de activar todo el
comportamiento por defecto con super.update()
rotaremos y empujaremos
la nave en base a la entrada del teclado, y dispararemos el arma si la
tecla de disparo es presionado.
Al escuchar la señal de muerte died
del
componente health, activamos la función onDied
si el jugador se queda
sin puntos de golpe. Cuando esto sucede, le decimos al barco que se
destruya a sí mismo.
La clase Gun
A continuación vamos a usar la clase Gun
:
1 |
package asteroids |
2 |
{
|
3 |
import engine.Entity; |
4 |
import engine.Weapon; |
5 |
/**
|
6 |
* ...
|
7 |
* @author Iain Lobb - iainlobb@gmail.com
|
8 |
*/
|
9 |
public class Gun extends Weapon |
10 |
{
|
11 |
public function Gun(entity:Entity) |
12 |
{
|
13 |
super(entity); |
14 |
}
|
15 |
|
16 |
override public function fire():void |
17 |
{
|
18 |
var bullet:Bullet = new Bullet(); |
19 |
bullet.targets = entity.targets; |
20 |
bullet.body.x = entity.body.x; |
21 |
bullet.body.y = entity.body.y; |
22 |
bullet.body.angle = entity.body.angle; |
23 |
bullet.physics.thrust(10); |
24 |
entity.entityCreated.dispatch(bullet); |
25 |
|
26 |
super.fire(); |
27 |
}
|
28 |
|
29 |
}
|
30 |
|
31 |
}
|
¡Este es corto! Simplemente anulamos la función fire()
para crear una nueva Bullet
cada
vez que el jugador se dispara. Después
de hacer coincidir la posición y la rotación de la bala con el barco, y
empujándolo en la dirección correcta, enviamos entityCreated
para que
pueda ser añadido al juego.
Una gran cosa acerca de esta clase Gun
es que
es utilizado por el jugador y las naves enemigas.
La clase Bullet
Un Gun
crea una instancia de esta clase Bullet
:
1 |
package asteroids |
2 |
{
|
3 |
import engine.Body; |
4 |
import engine.Entity; |
5 |
import engine.Physics; |
6 |
import engine.View; |
7 |
import flash.display.Sprite; |
8 |
/**
|
9 |
* ...
|
10 |
* @author Iain Lobb - iainlobb@gmail.com
|
11 |
*/
|
12 |
public class Bullet extends Entity |
13 |
{
|
14 |
public var age:int; |
15 |
|
16 |
public function Bullet() |
17 |
{
|
18 |
body = new Body(this); |
19 |
body.radius = 5; |
20 |
|
21 |
physics = new Physics(this); |
22 |
|
23 |
view = new View(this); |
24 |
view.sprite = new Sprite(); |
25 |
view.sprite.graphics.beginFill(0xFFFFFF); |
26 |
view.sprite.graphics.drawCircle(0, 0, body.radius); |
27 |
}
|
28 |
|
29 |
override public function update():void |
30 |
{
|
31 |
super.update(); |
32 |
|
33 |
for each (var target:Entity in targets) |
34 |
{
|
35 |
if (body.testCollision(target)) |
36 |
{
|
37 |
target.health.hit(1); |
38 |
destroy(); |
39 |
return; |
40 |
}
|
41 |
}
|
42 |
|
43 |
age++; |
44 |
if (age > 20) view.alpha -= 0.2; |
45 |
if (age > 25) destroy(); |
46 |
}
|
47 |
|
48 |
}
|
49 |
|
50 |
}
|
El constructor instancia y configura el cuerpo, la física y la vista. En
la función de actualización, ahora puede ver la lista de objetivos
targets
se vuelve util, ya que el bucle a través de todas las cosas que
queremos golpear y ver si alguno de ellos están intersectando la bala.
Este sistema de colisión no escalaría a miles de balas, pero está bien para la mayoría de los juegos casuales.
Si la bala tiene más de 20
marcos de edad, comenzamos a desaparecer, y si es más de 25 marcos lo
destruimos. Al
igual que con el arma Gun
, la bala Bullet
es utilizado por el jugador y el enemigo
- las instancias sólo tienen una lista de objetivos diferentes.
Hablando de que...
La Clase EnemyShip
Ahora veamos esa nave enemiga:
1 |
package asteroids |
2 |
{
|
3 |
import engine.Body; |
4 |
import engine.Entity; |
5 |
import engine.Health; |
6 |
import engine.Physics; |
7 |
import engine.View; |
8 |
import flash.display.GraphicsPathWinding; |
9 |
import flash.display.Sprite; |
10 |
/**
|
11 |
* ...
|
12 |
* @author Iain Lobb - iainlobb@gmail.com
|
13 |
*/
|
14 |
public class EnemyShip extends Entity |
15 |
{
|
16 |
protected var turnDirection:Number = 1; |
17 |
|
18 |
public function EnemyShip() |
19 |
{
|
20 |
body = new Body(this); |
21 |
body.x = 750; |
22 |
body.y = 550; |
23 |
|
24 |
physics = new Physics(this); |
25 |
physics.drag = 0.9; |
26 |
|
27 |
view = new View(this); |
28 |
view.sprite = new Sprite(); |
29 |
view.sprite.graphics.lineStyle(1.5, 0xFFFFFF); |
30 |
view.sprite.graphics.drawPath(Vector.<int>([1, 2, 2, 2, 2]), |
31 |
Vector.<Number>([ 0, 10, 10, -10, 0, 0, -10, -10, 0, 10]), |
32 |
GraphicsPathWinding.NON_ZERO); |
33 |
|
34 |
health = new Health(this); |
35 |
health.hits = 5; |
36 |
health.died.add(onDied); |
37 |
|
38 |
weapon = new Gun(this); |
39 |
}
|
40 |
|
41 |
override public function update():void |
42 |
{
|
43 |
super.update(); |
44 |
|
45 |
if (Math.random() < 0.1) turnDirection = -turnDirection; |
46 |
|
47 |
body.angle += turnDirection * 0.1; |
48 |
|
49 |
physics.thrust(Math.random()); |
50 |
|
51 |
if (Math.random() < 0.05) weapon.fire(); |
52 |
}
|
53 |
|
54 |
protected function onDied(entity:Entity):void |
55 |
{
|
56 |
destroy(); |
57 |
}
|
58 |
|
59 |
}
|
60 |
|
61 |
}
|
Como puede ver, es bastante similar a la clase de la nave del jugador. La
única diferencia real es que en la función update()
, en lugar de tener
el control del jugador a través del teclado, tenemos algo de "estupidez
artificial" para hacer que el buque pase y dispare al azar.
La clase Asteroid
El otro tipo de entidad en el que el jugador puede disparar es el propio asteroide:
1 |
package asteroids |
2 |
{
|
3 |
import engine.Body; |
4 |
import engine.Entity; |
5 |
import engine.Health; |
6 |
import engine.Physics; |
7 |
import engine.View; |
8 |
import flash.display.Sprite; |
9 |
/**
|
10 |
* ...
|
11 |
* @author Iain Lobb - iainlobb@gmail.com
|
12 |
*/
|
13 |
public class Asteroid extends Entity |
14 |
{
|
15 |
public function Asteroid() |
16 |
{
|
17 |
body = new Body(this); |
18 |
body.radius = 20; |
19 |
body.x = Math.random() * 800; |
20 |
body.y = Math.random() * 600; |
21 |
|
22 |
physics = new Physics(this); |
23 |
physics.velocityX = (Math.random() * 10) - 5; |
24 |
physics.velocityY = (Math.random() * 10) - 5; |
25 |
|
26 |
view = new View(this); |
27 |
view.sprite = new Sprite(); |
28 |
view.sprite.graphics.lineStyle(1.5, 0xFFFFFF); |
29 |
view.sprite.graphics.drawCircle(0, 0, body.radius); |
30 |
|
31 |
health = new Health(this); |
32 |
health.hits = 3; |
33 |
health.hurt.add(onHurt); |
34 |
}
|
35 |
|
36 |
override public function update():void |
37 |
{
|
38 |
super.update(); |
39 |
|
40 |
for each (var target:Entity in targets) |
41 |
{
|
42 |
if (body.testCollision(target)) |
43 |
{
|
44 |
target.health.hit(1); |
45 |
destroy(); |
46 |
return; |
47 |
}
|
48 |
}
|
49 |
}
|
50 |
|
51 |
protected function onHurt(entity:Entity):void |
52 |
{
|
53 |
body.radius *= 0.75; |
54 |
view.scale *= 0.75; |
55 |
|
56 |
if (body.radius < 10) |
57 |
{
|
58 |
destroy(); |
59 |
return; |
60 |
}
|
61 |
|
62 |
var asteroid:Asteroid = new Asteroid(); |
63 |
asteroid.targets = targets; |
64 |
group.push(asteroid); |
65 |
asteroid.group = group; |
66 |
asteroid.body.x = body.x; |
67 |
asteroid.body.y = body.y; |
68 |
asteroid.body.radius = body.radius; |
69 |
asteroid.view.scale = view.scale; |
70 |
entityCreated.dispatch(asteroid); |
71 |
}
|
72 |
|
73 |
}
|
74 |
|
75 |
}
|
Esperemos que te acostumbras a cómo estas clases de entidad parecen ahora.
En el constructor inicializamos nuestros componentes y aleatorizamos la posición y la velocidad.
En
la función update()
verificamos las colisiones con nuestra lista de
objetivos, que en este ejemplo solo tendrá un solo elemento, la nave del
jugador. Si encontramos una colisión hacemos daño al objetivo y luego
destruimos el asteroide. Por
otro lado, si el asteroide está dañado (es decir, es golpeado por una
bala de jugador), lo reducimos y creamos un segundo asteroide, creando
la ilusión de que ha sido destruido en dos partes. Sabemos cuándo
hacerlo escuchando la señal de "daño" del componente de Salud.
La clase
AsteroidsGame
Por último, veamos la clase AsteroidsGame que controla todo el espectáculo:
1 |
package asteroids |
2 |
{
|
3 |
import engine.Entity; |
4 |
import engine.Game; |
5 |
import flash.events.MouseEvent; |
6 |
import flash.filters.GlowFilter; |
7 |
import flash.text.TextField; |
8 |
/**
|
9 |
* ...
|
10 |
* @author Iain Lobb - iainlobb@gmail.com
|
11 |
*/
|
12 |
public class AsteroidsGame extends Game |
13 |
{
|
14 |
public var players:Vector.<Entity> = new Vector.<Entity>(); |
15 |
public var enemies:Vector.<Entity> = new Vector.<Entity>(); |
16 |
public var messageField:TextField; |
17 |
|
18 |
public function AsteroidsGame() |
19 |
{
|
20 |
|
21 |
}
|
22 |
|
23 |
override protected function startGame():void |
24 |
{
|
25 |
var asteroid:Asteroid; |
26 |
for (var i:int = 0; i < 10; i++) |
27 |
{
|
28 |
asteroid = new Asteroid(); |
29 |
asteroid.targets = players; |
30 |
asteroid.group = enemies; |
31 |
enemies.push(asteroid); |
32 |
addEntity(asteroid); |
33 |
}
|
34 |
|
35 |
var ship:Ship = new Ship(); |
36 |
ship.targets = enemies; |
37 |
ship.destroyed.add(onPlayerDestroyed); |
38 |
players.push(ship); |
39 |
addEntity(ship); |
40 |
|
41 |
var enemyShip:EnemyShip = new EnemyShip(); |
42 |
enemyShip.targets = players; |
43 |
enemyShip.group = enemies; |
44 |
enemies.push(enemyShip); |
45 |
addEntity(enemyShip); |
46 |
|
47 |
filters = [new GlowFilter(0xFFFFFF, 0.8, 6, 6, 1)]; |
48 |
|
49 |
update(); |
50 |
|
51 |
render(); |
52 |
|
53 |
isPaused = true; |
54 |
|
55 |
if (messageField) |
56 |
{
|
57 |
addChild(messageField); |
58 |
}
|
59 |
else
|
60 |
{
|
61 |
createMessage(); |
62 |
}
|
63 |
|
64 |
stage.addEventListener(MouseEvent.MOUSE_DOWN, start); |
65 |
}
|
66 |
|
67 |
protected function createMessage():void |
68 |
{
|
69 |
messageField = new TextField(); |
70 |
messageField.selectable = false; |
71 |
messageField.textColor = 0xFFFFFF; |
72 |
messageField.width = 600; |
73 |
messageField.scaleX = 2; |
74 |
messageField.scaleY = 3; |
75 |
messageField.text = "CLICK TO START"; |
76 |
messageField.x = 400 - messageField.textWidth; |
77 |
messageField.y = 240; |
78 |
addChild(messageField); |
79 |
}
|
80 |
|
81 |
protected function start(event:MouseEvent):void |
82 |
{
|
83 |
stage.removeEventListener(MouseEvent.MOUSE_DOWN, start); |
84 |
isPaused = false; |
85 |
removeChild(messageField); |
86 |
stage.focus = stage; |
87 |
}
|
88 |
|
89 |
protected function onPlayerDestroyed(entity:Entity):void |
90 |
{
|
91 |
gameOver(); |
92 |
}
|
93 |
|
94 |
protected function gameOver():void |
95 |
{
|
96 |
addChild(messageField); |
97 |
isPaused = true; |
98 |
stage.addEventListener(MouseEvent.MOUSE_DOWN, restart); |
99 |
}
|
100 |
|
101 |
protected function restart(event:MouseEvent):void |
102 |
{
|
103 |
stopGame(); |
104 |
startGame(); |
105 |
|
106 |
stage.removeEventListener(MouseEvent.MOUSE_DOWN, restart); |
107 |
isPaused = false; |
108 |
removeChild(messageField); |
109 |
stage.focus = stage; |
110 |
}
|
111 |
|
112 |
override protected function stopGame():void |
113 |
{
|
114 |
super.stopGame(); |
115 |
|
116 |
players.length = 0; |
117 |
enemies.length = 0; |
118 |
}
|
119 |
|
120 |
override protected function update():void |
121 |
{
|
122 |
super.update(); |
123 |
|
124 |
for each (var entity:Entity in entities) |
125 |
{
|
126 |
if (entity.body.x > 850) entity.body.x -= 900; |
127 |
if (entity.body.x < -50) entity.body.x += 900; |
128 |
|
129 |
if (entity.body.y > 650) entity.body.y -= 700; |
130 |
if (entity.body.y < -50) entity.body.y += 700; |
131 |
}
|
132 |
|
133 |
if (enemies.length == 0) gameOver(); |
134 |
}
|
135 |
|
136 |
}
|
137 |
}
|
Esta clase es bastante larga (bueno, más de 100 líneas!) Porque hace muchas cosas.
En startGame()
crea y configura 10 asteroides, la nave y la nave
enemiga, y también crea el mensaje "CLICK TO START".
La
función start()
reanuda el juego y elimina el mensaje, mientras que la
función gameOver
hace una pausa en el juego y restaura el mensaje. La
función restart()
escucha un clic del ratón en la pantalla Game
Over - cuando esto sucede, detiene el juego y lo vuelve a iniciar.
La
función update()
recorre a través de todos los enemigos y
distorsiona cualquiera que haya quedado fuera de la pantalla, así como
la comprobación de la condición de victoria, que es que no hay enemigos
en la lista de enemigos.
Tomándolo más lejos
Este es un motor de huesos muy simple y un juego simple, así que ahora vamos a pensar en maneras de expandirlo.
- Podríamos agregar un valor de prioridad para cada entidad y ordenar la lista antes de cada actualización, de modo que podamos asegurarnos de que algunos tipos de Entidad siempre se actualicen después de otros tipos.
- Podríamos usar el agrupamiento de objetos para reutilizar objetos muertos (por ejemplo, viñetas) en lugar de crear cientos de nuevos.
- Podríamos añadir un sistema de cámara para que podamos desplazar y ampliar la escena. Podríamos ampliar los componentes de Cuerpo y Física para añadir soporte para Box2D u otro motor de física.
- Podríamos crear un componente de inventario, para que las entidades puedan transportar elementos.
Además de ampliar los componentes
individuales, es posible que a veces
necesitamos ampliar la interfaz IEntity
para crear tipos especiales de
Entity con componentes especializados.
Por
ejemplo, si estamos haciendo un juego de plataforma, y tenemos un
nuevo componente que maneja todas las cosas muy específicas que un
personaje de juego de plataforma necesita - están en el suelo, están
tocando una pared, cuánto tiempo han sido En el aire, pueden saltar de
doble salto, etc. - otras entidades también podrían tener acceso a esta
información. Pero no es parte de la API Entity principal, que se
mantiene intencionalmente muy general. Así que tenemos que definir una
nueva interfaz, que proporciona acceso
a todos los componentes de entidad estándar, pero añade acceso al
componente PlatformController
.
Para esto, haríamos algo como:
1 |
package platformgame |
2 |
{
|
3 |
import engine.IEntity; |
4 |
|
5 |
/**
|
6 |
* ...
|
7 |
* @author Iain Lobb - iainlobb@gmail.com
|
8 |
*/
|
9 |
public interface IPlatformEntity extends IEntity |
10 |
{
|
11 |
function set platformController(value:PlatformController):void; |
12 |
function get platformController():PlatformController; |
13 |
}
|
14 |
}
|
Cualquier entidad que necesite funcionalidad de "plataforma"
implementa esta interfaz, permitiendo que otras entidades interactúen
con el componente PlatformController
.
Conclusiones
Incluso atreviéndome a escribir sobre la arquitectura del juego, temo que estoy moviendo un nido de opinión de avispones - pero eso es (en su mayoría) siempre una buena cosa, y espero que por lo menos te he hecho pensar en cómo organizar tu código.
En última instancia, no creo que usted debe ponerse demasiado colgado sobre cómo estructurar las cosas; Lo que funciona para que usted haga su juego es la mejor estrategia. Sé que hay sistemas mucho más avanzados que el que aquí delineo, que resuelven una gama de problemas más allá de los que he discutido, pero pueden tender a comenzar a parecer muy desconocido si estás acostumbrado a una arquitectura basada en herencia tradicional.
Me
gusta el acercamiento que he sugerido aquí porque permite que el código
sea organizado por el propósito, en pequeñas clases enfocadas, mientras
que proporciona una interfaz extensible y sin
el confiar en características dinámicas del lenguaje o las búsquedas de
String
. Si
desea modificar el comportamiento de un componente en particular, puede
ampliar ese componente y anular los métodos que desee cambiar. Las
clases tienden a permanecer muy cortas, así que nunca me encuentro
desplazándome por miles de líneas para encontrar el código que busco.
Lo mejor de todo, soy capaz de tener un solo motor que es lo suficientemente flexible como para usar en todos los juegos que hago, ahorrándome una enorme cantidad de tiempo.