Enemigos, disparos y explosiones
Existen
multiples técnicas relacionadas con la inteligencia artificial (IA) y
que son ampliamente utilizadas en programación de juegos. La IA es un
tópico lo suficientemente extenso como para rellenar varios libros del
tamaño del que tienes ahora entre manos. Aún así, exploraremos algunas
sencillas técnicas que nos permitiran dotar a los aviones enemigos de
nuestro juego de una chispa vital. También haremos diaparar a los
aviones enemigos y al nuestro, explosiones incluidas.
Tipos de Inteligencia
Hay, al menos, tres tendencias dentro del campo de la inteligencia artificial.
- Redes neuronales
- Algoritmos de búsqueda
- Sistemas basados en conocimiento
Son
tres enfoque diferentes que tratan de buscar un fin común. No hay un
enfoque mejor que los demás, la elección de uno u otro depende de la
aplicación.
Una
red neuronal trata de simular el funcionamiento del cerebro humano. El
elemento básico de una red neuronal es la neurona. En una red neuronal,
un conjunto de neuronas trabajan al unísono para resolver un problema.
Al igual que un niño tiene que aprender al nacer, una red de neuronas
artificial tiene que ser entrenada para poder realizar su cometido.
Este aprendizaje puede ser supervisado o no supervisado, dependiendo si
hace falta intervención humana para entrenar a la red de neuronas.
Este entrenamiento se realiza normalmente mediante ejemplos. La
aplicación de las redes neuronales es efectiva en campos en los que no
existen algoritmos concretos que resuelvan un problema o sean demasiado
complejos de computar. Donde más se aplican es en problemas de
reconocimiento de patrones y pronósticos.
El
segundo enfoque es el de los algoritmos de búsqueda. Es necesario un
conocimiento razonable sobre estructuras de datos como árboles y grafos.
Una de las aplicaciones interesantes, sobre todo para videojuegos, es
la búsqueda de caminos (pathfinding). Seguramente has jugado a juegos
de estrategia como Starcraft, Age of Empires y otros del estilo. Puedes
observar que cuando das la orden de movimiento a uno de los pequeños
personajes del juego, éste se dirige al punto indicado esquivando los
obstáculos que encuantra en su camino. Este algoritmo de búsqueda en
grafos es llamado A*. La figura 7.1. es un ejemplo de árbol. Supongamos
que es el mapa de un juego en el que nuestra misión es escapar de una
casa.
Cada
círculo representa un nodo del árbol. El número que encierra es el
número de habitación. Si quisieramos encontrar la salida, usaríamos un
algoritmo de búsqueda (por ejemplo A*) para recorrer todos los posibles
caminos y quedarnos con el que nos interesa. El objetivo es buscar el
nodo 8, que es el que tiene la puerta de salida. El camino desde la
habitación 1 es: 1 4 5 8. El algoritmo A* además de encontrar el nodo
objetivo, nos asegura que es el camino más corto. No vamos a entrar en
más detalle, ya que cae fuera de las pretensiones de este libro
profundizar en la implementación de algoritmos de búsqueda. Por último,
los sistemas basados en reglas se sirven, valga la redundancia, de
conjuntos de reglas y hechos. Los hechos son informaciones relativas al
problema y a su universo. Las reglas son precisamente eso, reglas
aplicables a los elementos del universo y que permiten llegar a
deducciones simples. Veamos un ejemplo:
- Hechos: Las moscas tienen alas.
Las hormigas no tienen alas.
Reglas: Si (x) tiene alas, entonces vuela.
Un
sistema basado en conocimiento, con estas reglas y estos hechos es
capaz de deducir dos cosas. Que las moscas vuelan y que las hormigas
no.
- Si (la mosca) tiene alas, entonces vuela.
Uno de los problemas de los sistemas basados en conocimiento es que pueden ocurrir situaciones como estas.
- Si (la gallina) tiene alas, entonces vuela.
Desgraciadamente
para las gallinas, éstas no vuelan. Puedes observar que la
construcción para comprobar reglas es muy similar a la construcción
IF/THEN de los lenguajes de programación.
Comportamientos y máquinas de estado
Una
máquina de estados está compuesta por una serie de estados y una serie
de reglas que indican en que casos se pasa de un estado a otro. Estas
máquinas de estados nos perniten modelar comportamientos en los
personajes y elementos del juego. Vamos a ilustrarlo con un ejemplo.
Imagina que en el hipotético juego que hemos planteado unas líneas más
arriba hay un zombie. El pobre no tiene una inteligencia demasiado
desarrollada y sólo es capaz de andar hasta que se pega contra la
pared. Cuando sucede esto, lo único que sabe hacer es girar 45 grados a
la derecha y continuar andando. Vamos a modelar el comportamiento del
zombie con una máquina de estados. Para ello primero tenemos que
definir los posibles estados.
- Andando (estado 1)
- Girando (estado 2)
Las reglas que hacen que el zombie cambie de un estado a otro son las siguientes.
- Si está en el estado 1 y choca con la pared pasa al estado 2.
- Si esta en el estado 2 y ha girado 45 grados pasa al estado 1.
Con estos estados y estas reglas podemos construir el grafo que representa a nuestra máquina de estados.
La implementación de la máquina de estado es muy sencilla. La siguiente función simula el comportamiento del zombie.
int angulo; void zombie() { int state, angulo_tmp; // estado 1 if (state == 1) { andar(); if (colision()) { state=2; angulo_tmp=45; } } // estado 2 if (state == 2) { angulo_tmp=angulo_tmp-1; angulo=angulo+1; if (angulo_tmp <= 0) { state=1; } } }
Éste es un ejemplo
bastante sencillo, sin embargo utilizando este método podrás crear
comportamientos inteligentes de cierta complejidad.
Enemigos
En
nuestro juego vamos a tener dos tipos de aviones enemigos. El primero
de ellos es un avión que cruzará la pantalla en diagonal, en dirección
de nuestro avión. Al alcanzar una distancia suficiente a nosotros,
disparará un proyectil. El segundo enemigo es algo menos violento, ya
que no disparará. Sin embargo, al alcanzar cierta posición de la
pantalla realizará un peligroso cambio de treyectoria que nos puede
pillar desprevenidos. He aquí las máquinas de estado de ambos
comprtamientos.
Para
representar las naves enemigas, crearemos una clase llamada Enemy. Como
al fin y al cabo, las naves enemigas no dejan de ser sprites, vamos a
crear la clase Enemy heredando los métodos y atributos de la clase
Sprite y añadiendo aquello que necesitemos.
class Enemy extends Sprite { private int type,state,deltaX,deltaY; public void setState(int state) { this.state=state; } public int getState(int state) { return state; } public void setType(int type) { this.type=type; } public int getType() { return type; } public void doMovement() { Random random = new java.util.Random(); // Los enemigos de tipo 2 cambiaran su trayectoria // al alcanzar una posición determinada (pos. 50) if (type == 2 && getY() > 50 && state != 2) { // paso al estado 2 (movimiento diagonal) state = 2; if ((Math.abs(random.nextInt()) % 2) + 1 == 1) { deltaX=2; } else { deltaX=-2; } } // movemos la nave setX(getX()+deltaX); setY(getY()+deltaY); } public void init(int xhero) { deltaY=3; deltaX=0; if (type == 1) { if (xhero > getX()) { deltaX=2; } else { deltaX=-2; } } } // Sobrecarga del método draw de la clase Sprite public void draw (javax.microedition.lcdui.Graphics g) { selFrame(type); // llamamos al método 'draw' de la clase padre (Sprite) super.draw(g); } public Enemy(int nFrames) { super(nFrames); } }
A los atributos de la
clase Sprite añadimos cuatro más. El atributo type, indicará cuál es el
tipo de enemigo. En nuestro caso hay dos tipos. Para manejar este
atributo dotamos a nuestra clase de los métodos getType() y setType() para consultar y establecer el tipo del enemigo.
El
atributo state mantendrá el estado de la nave. La nave de tipo 2, es
decir la que cambia su trayectoria, tiene dos estado. En el estado 1
simplemente avanza en horizontal. En el estado 2 su trayectoria es
diagonal. Para manejar el estado de los enemigos añadimos las clases getState() y setState() para consultar y establecer el estado de los enemigos.
Los dos atributos que nos quedan son deltaX y deltaY,
que contienen los desplazamientos en el eje horizontal y vertical
respectivamente que se producirá en cada vuelta del game loop.
Al
crear una instancia de nuestra clase, lo primero que hemos de hacer en
el constructor es llamar a la clase padre, que es Sprite, para pasarle
el parámetro que necesita para reservar el número de frames. También
para inicializar el sprite.
super(nFrames);
Vamos tambien a sobrecargar el método draw()
del método Sprite. En este método, primero seleccionaremos el tipo de
avión que vamos a poner en la pantalla según su tipo, después, llamamos
al método draw() de la clase padre.
Nuestro
enemigo de tipo 1 debe tomar una trayectoria dependiendo de la posicón
de nuestro avión. Para ello, necesitamos una forma de comunicarle a la
nave enemiga dicha posición. Hemos creado un método llamado init() a la que le pasamos como parámetro la posición actual de nuestro avión. En este método ponemos los atributos deltaX y deltaY a sus valores iniciales.
Por último necesitaremos un método que se encargue de realizar el movimiento de la nave. Este método es doMovement(). Su función principal es actualizar la posición de la nave enemiga según los atributos deltaX y deltaY. También comprobamos la posición del enemigo de tipo 1 para cambiar su estado cuando sea necesario.
Ya disponemos de nuestra clase Enemy
para manejar a los aviones enemigos. En nuestro juego permitiremos un
máximo de 6 enemigos simultáneos en pantalla (realmente habrá menos),
así que creamos un array de elementos de tipo Enemy. El
siguiente paso es inicializar cada uno de estos seis elementos. Vamos a
cargar dos frames, uno para la nave de tipo 1 y otro para la de tipo
2. Dependiendo del tipo de la nave, seleccionaremos un frame u otro
antes de dibujar el avión.
private Enemy[] enemies=new Enemy[7]; // Inicializar enemigos for (i=1 ; i<=6 ; i++) { enemies[i]=new Enemy(2); enemies[i].addFrame(1,"/enemy1.png"); enemies[i].addFrame(2,"/enemy2.png"); enemies[i].off(); }
Durante el trascurso de
nuestro juego aparecerá un enemigo cada 20 ciclos del game loop. Cuando
necesitemos crear un enemigo, hay que buscar una posición libre en el
array de enemigos. Si hay alguno libre, ponemos su estado inicial
(posición, tipo, etc...) de forma aleatoria y lo iniciamos (lo
activamos).
// Creamos un enemigo cada 20 ciclos if (cicle%20 == 0) { freeEnemy=0; // Buscar un enemigo libre for (i=1 ; i<=6 ; i++) { if (!enemies[i].isActive()) { freeEnemy=i; } } // Asignar enemigo si hay una posición libre // en el array de enemigos if (freeEnemy != 0) { enemies[freeEnemy].on(); enemies[freeEnemy].setX((Math.abs(random.nextInt()) % getWidth()) + 1); enemies[freeEnemy].setY(0); enemies[freeEnemy].setState(1); enemies[freeEnemy].setType((Math.abs(random.nextInt()) % 2) + 1); enemies[freeEnemy].init(hero.getX()); } }
En cada ciclo del game loop, hemos de actualizar la posición de cada enemigo y comprobar si ha salido de la pantalla.
// Mover los enemigos for (i=1 ; i<=6 ; i++) { if (enemies[i].isActive()) { enemies[i].doMovement(); } // Mirar si la nave salió de la pantalla if ((enemies[i].getY() > getHeight()) || (enemies[i].getY() < 0)) { enemies[i].off(); } }
Disparos y explosiones
Ahora
que conocemos una técnica para que nuestros aviones enemigos sean
capaces de comportarse tal y como queremos que lo hagan, estamos muy
cerca de poder completar nuestro juego. Lo siguiente que vamos a hacer
es añadir la capacidad de disparar a nuestro avión. La gestión de los
disparos y las explosiones va a ser muy similar a la de los aviones
enemigos. Vamos a crear una clase a la que llamaremos Bullet, descendiente de la clase Sprite y que representará un disparo.
class Bullet extends Sprite { private int owner; public Bullet(int nFrames) { super(nFrames); } public void setOwner(int owner) { this.owner=owner; } public int getOwner() { return owner; } public void doMovement() { // si owner = 1 el disparo es nuestro // si no, es del enemigo if (owner == 1) { setY(getY()-6); } else { setY(getY()+6); } } // Sobrecarga del método draw de la clase Sprite public void draw (javax.microedition.lcdui.Graphics g) { selFrame(owner); // llamamos al método 'draw' de la clase padre (Sprite) super.draw(g); } }
Añadimos un atributo llamado owner
que indica a quien pertenece el disparo (a un enemigo o a nuestro
avión). Para gestionar este atributo disponemos de los métodos getOwner() y setOwner().
Este
atributo lo vamos a utilizar para decidir si movemos el disparo hacia
arriba o hacia abajo y para comprobar las colisiones.
En cuanto a las explosiones, vamos a crear una clase llamada Explode, también descendiente de Sprite.
class Explode extends Sprite { private int state; public Explode(int nFrames) { super(nFrames); state=1; } public void setState(int state) { this.state=state; } public int getState() { return state; } public void doMovement() { state++; if (state > super.frames()) super.off(); } // Sobrecarga del método draw de la clase Sprite public void draw (javax.microedition.lcdui.Graphics g) { selFrame(state); // llamamos al método 'draw' de la clase padre (Sprite) super.draw(g); } }
El único atributo que añadimos es state,
que nos indicará el estado de la explosión. En la práctica, el estado
de la explosión va a ser el frame actual de su animación interna, de
hecho, su clase doMovement() lo único que hace es aumentar el frame para realizar la animación.
La
inicialización y gestión de explosiones y disparos es idéntica a la de
los aviones enemigos, por lo que no vamos a entrar en mucho más
detalle.
Nos resta comprobar las colisiones entre los sprites del juego. Vamos a realizar tres comprobaciones.
- Colisión héroe – enemigo
- Colisión héroe – disparo
- Colisión enemigo – disparo
Ten
en cuenta que hay 6 posibles enemigos y 6 posibles disparos a la vez en
pantalla, por lo que hay que realizar todas las combinaciones
posibles.
// Colisión heroe-enemigo
for (i=1 ; i<=6 ; i++) {
if (hero.collide(enemies[i])&&enemies[i].isActive()&&shield == 0) {
createExplode(hero.getX(),hero.getY());
createExplode(enemies[i].getX(),enemies[i].getY());
enemies[i].off();
collision=true;
}
}
// Colisión heroe-disparo
for (i=1 ; i<=6 ; i++) {
if (aBullet[i].isActive() && hero.collide(aBullet[i]) &&
aBullet[i].getOwner() != 1 && shield == 0) {
createExplode(hero.getX(),hero.getY());
aBullet[i].off();
collision=true;
}
}
// colisión enemigo-disparo
for (i=1 ; i<=6 ; i++) {
if (aBullet[i].getOwner() == 1 && aBullet[i].isActive()) {
for (j=1 ; j<=6 ; j++) {
if (enemies[j].isActive()) {
if (aBullet[i].collide(enemies[j])) {
createExplode(enemies[j].getX(),enemies[j].getY());
enemies[j].off();
aBullet[i].off();
score+=10;
}
}
}
}
}
for (i=1 ; i<=6 ; i++) {
if (hero.collide(enemies[i])&&enemies[i].isActive()&&shield == 0) {
createExplode(hero.getX(),hero.getY());
createExplode(enemies[i].getX(),enemies[i].getY());
enemies[i].off();
collision=true;
}
}
// Colisión heroe-disparo
for (i=1 ; i<=6 ; i++) {
if (aBullet[i].isActive() && hero.collide(aBullet[i]) &&
aBullet[i].getOwner() != 1 && shield == 0) {
createExplode(hero.getX(),hero.getY());
aBullet[i].off();
collision=true;
}
}
// colisión enemigo-disparo
for (i=1 ; i<=6 ; i++) {
if (aBullet[i].getOwner() == 1 && aBullet[i].isActive()) {
for (j=1 ; j<=6 ; j++) {
if (enemies[j].isActive()) {
if (aBullet[i].collide(enemies[j])) {
createExplode(enemies[j].getX(),enemies[j].getY());
enemies[j].off();
aBullet[i].off();
score+=10;
}
}
}
}
}