martes, 4 de mayo de 2010

Programación de juegos para móviles con J2ME Parte VIII

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;
}
}
}
}
}


El resultado de nuestro juego puede verse en la siguiente figura. El listado fuente completo del juego puede consultarse en el apéndice A.

No hay comentarios:

Publicar un comentario