martes, 13 de enero de 2015

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

Un Universo en tu móvil

Ahora que hemos desarrollado una herramienta para el control de sprites, vamos a aprender a sacarle partido. Con nuestra librería seremos capaces de mostrar en la pantalla del dispositivo todo lo que va ocurriendo en el juego, pero también hemos de ser capaces de leer la información desde el teclado del móvil para responder a las instrucciones que da el jugador. También es importante que el movimiento del juego sea suave y suficientemente rápido. En este capítulo examinaremos las capacidades de animación de los midlets, incluido el scrolling, así como la interfaz con el teclado.

Animando nuestro avión

Lectura del teclado

Toda aplicación interactiva necesita un medio para comunicarse con el usuario. Vamos a utilizar para ello tres métodos que nos ofrece la clase Canvas. Los métodos keyPressed(), keyReleased() y keyRepeated(). Estos métodos son llamados cuando se produce un evento relacionado con la pulsación de una tecla. keyPressed() es llamado cuando se produce la pulsación de una tecla, y cuando soltamos la tecla es invocado el método keyReleased(). El método keyRepeated() es invocado de forma repetitiva cuando dejamos una tecla pulsada.
Los tres métodos recogen como parámetro un número entero, que es el código unicode de la tecla pulsada. La clase Canvas también nos ofrece el método getGameAction(), que convertirá el código a una constante independiente del fabricante del dispositivo. La siguiente tabla, muestra una lista de constantes de códigos estándar.
Constantes Teclas
KEY_NUM0, KEY_NUM1, KEY_NUM2, KEY_NUM3, KEY_NUM4, 
KEY_NUM5, KEY_NUM6, KEY_NUM7, KEY_NUM8, KEY_NUM9
Teclas numéricas
KEY_POUND Tecla ‘almohadilla’
KEY_STAR Tecla asterisco
GAME_A, GAME_B, GAME_C, GAME_D Teclas especiales de juego
UP Arriba
DOWN Abajo
LEFT Izquierda
RIGHT Derecha
FIRE Disparo
Los fabricantes de dispositivos móviles suelen reservar unas teclas con funciones más o menos precisas de forma que todos los juegos se controlen de forma similar. Otros, como el caso del Nokia 7650 ofrecen un mini-joystick. Usando las constantes de la tabla anterior, podemos abstraernos de las peculiaridades de cada fabricante. Por ejemplo, en el Nokia 7650, cuando movamos el joystick hacia arriba se generara el código UP.
Vemos un ejemplo de uso:
public void keyPressed(int keyCode) {

    int action=getGameAction(keyCode);

    switch (action) {

        case FIRE:
            // Disparar
            break;
        case LEFT:
            // Mover a la izquierda
            break;
        case RIGHT:
            // Mover a la derecha
            break;
        case UP:
            // Mover hacia arriba
            break;
        case DOWN:
            // Mover hacia abajo
            break;
    }
}
Puede parecer lógico utilizar keyRepeated() para controlar un sprite en la pantalla, ya que nos interesa que mientras pulsemos una tecla, este se mantenga en movimiento. En principio esta sería la manera correcta de hacerlo, pero en la practica, no todos los dispositivos soportan la autorepetición de teclas (incluido el emulador de Sun). Vamos a solucionarlo con el uso de los otros dos métodos. Lo que queremos conseguir es que en el intervalo de tiempo que el jugador está pulsando una tecla, se mantenga la animación. Este intervalo de tiempo es precisamente el transcurrido entre que se produce la llamada al método keyPressed() y la llamada a keyReleased(). Un poco más abajo veremos como se implementa esta técnica.

Threads

Comenzamos este libro con una introducción a Java. De forma intencionada, y debido a lo voluminoso que es el lenguaje Java, algunos temas no fueron cubiertos. Uno de estos temas fueron los threads. Vamos a verlos someramente, ahora que ya estamos algo más familiarizados con el lenguaje, y lo utilizaremos en nuestro juego.
Muy probablemente el sistema operativo que utilizas tiene capacidades de multiproceso o multitarea. En un sistema de este tipo, puedes ejecutar varias aplicaciones al mismo tiempo. A cada una de estas aplicaciones las denominamos procesos. Podemos decir que el sistema operativo es capaz de ejecutar múltiples procesos simultáneamente. Sin embargo, en ocasiones es interesante que dentro de proceso se lancen uno o más subprocesos de forma simultánea. Vamos a utilizar un ejemplo para aclarar el concepto. Piensa en tu navegador web favorito. Cuando lo lanzas, es un proceso más dentro de la lista de procesos que se estan ejecutando en el sistema operativo. Ahora, supongamos que cargamos en el navegador una web llena de imágenes, e incluso algunas de ellas animadas. Si observas el proceso de carga, verás que no se cargan de forma secuencial una tras otra, sino que comienzan a cargarse varias a la vez. Esto es debido a que el proceso del navegador lanza varios subprocesos, uno por cada imagen, que se encargan de cargarlas, y en su caso, de animarlas de forma independiente al resto de imágenes. Cada uno de estos subprocesos se denomina thread (hilo o hebra en castellano).
En Java, un thread puede estar en cuatro estados posibles.
  • Ejecutándose: Está ejecutándose.
  • Preparado: Está preparado para pasar al estado de ejecución.
  • Suspendido: En espera de algún evento.
  • Terminado: Se ha finalizado la ejecución.
La clase que da soporte para los threads en Java es java.lang.Thread. En todo momento podremos tener acceso al thread que está en ejecución usando el método Thread.currentThread(). Para que una clase pueda ser ejecutada como un thread ha de implementar la interfaz java.lang.Runnable, en concreto, el método run(). Éste es el método que se ejecutará cuando lancemos el thread:
public class Hilo implements Runnable {
    public void run(){
        // código del thread
    }
}
Para arrancar un thread usamos su método start().
// Creamos el objeto (que implementa Runable)
Hilo miHilo = new Hilo();

// Creamos un objeto de la clase Thread
// Al que pasamos como parámetro al objeto miHilo
Thread miThread = new Thread( miHilo );

// Arrancamos el thread
miThread.start();
Si sólo vamos a utilizar el thread una vez y no lo vamos a reutilizar, siempre podemos simplificarlo.
Hilo miHilo = new Hilo();
new Thread(miHilo).start();
La clase Thread nos ofrece algunos métodos más, pero los más interesantes son stop(), que permite finalizar un thread, y sleep(int time), que lo detiene durante los milisegundos que le indiquemos como parámetro.

El Game Loop

Cuando jugamos a un juego parece que todo pasa a la vez, en el mismo instante, sin embargo, sabemos que un procesador sólo puede realizar una acción a la vez. La clave es realizar cada una de las acciones tan rápidamente como sea posible y pasar a la siguiente, de forma que todas se completen antes de visualizar el siguiente frame del juego.
El “game loop” o bucle de juego es el encargado de “dirigir” en cada momento que tarea se está realizando. En la figura 6.1. podemos ver un ejemplo de game loop, y aunque más o menos todos son similares, no tienen por que tener exactamente la misma estructura. Analicemos el ejemplo.
Lo primero que hacemos es leer los dispositivos de entrada para ver si el jugador ha realizado alguna acción. Si hubo alguna acción por parte del jugador, el siguiente paso es procesarla, esto es, actualizar su posición, disparar, etc..., dependiendo de qué acción sea. En el siguiente paso realizamos la lógica de juego, es decir, todo aquello que forma parte de la acción y que no queda bajo control del jugador, por ejemplo, el movimiento de los enemigos, cálculo de trayectoria de sus disparos, comprobación de colisiones entre la nave enemiga y la del jugador, etc... Fuera de la lógica del juego quedan otras tareas que realizamos en la siguiente fase, como son actualizar el scroll de fondo (si lo hubiera), activar sonidos (si fuera necesario), realizar trabajos de sincronización, etc.. Ya por último, nos resta volcar todo a la pantalla y mostrar el siguiente frame. Esta fase es llamada “fase de render”.
Normalmente, el game loop tendrá un aspecto similar a lo siguiente:
int done = 0;
while (!done) {
    // Leer entrada 
    // Procesar entrada 
    // Lógica de juego
    // Otras tareas
    // Mostrar frame
} 
Antes de que entremos en el game loop, tendremos que realizar múltiples tareas, como inicializar todas las estructuras de datos, etc...
El siguiente ejemplo es mucho más realista. Está implementado en un thread.
public void run() {
    iniciar();
    while (true) {

        // Actualizar fondo de pantalla
        doScroll();

        // Actualizar posición del jugador
        computePlayer();

        // Actualizar pantalla
        repaint();
        serviceRepaints();

        try {
            Thread.sleep(sleepTime);
        } catch (InterruptedException e) {
            System.out.println(e.toString());
        }
    }
}
Lo primero que hacemos es inicializar el estado del juego. Seguidamente entramos en el bucle principal del juego o game loop propiamente dicho. En este caso, es un bucle infinito, pero en un juego real, tendríamos que poder salir usando una variable booleana que se activara al producirse la destrucción de nuestro avión o cualquier otro evento que suponga la salida del juego.
Ya dentro del bucle, lo que hacemos es actualizar el fondo de pantalla -en la siguiente sección entraremos en los detalles de este proceso-, a continuación, calculamos la posición de nuestro avión para posteriormente forzar un repintado de la pantalla con una llamada a repaint() y serviceRepaints(). Por último, utilizamos el método sleep() perteneciente a la clase Thread para introducir un pequeño retardo. Este retardo habrá de ajustarse a la velocidad del dispositivo en que ejecutemos el juego.

Movimiento del avión

Para mover nuestro avión utilizaremos, como comentamos en la sección dedicada a la lectura del teclado, los métodos keyPressed() y keyReleased(). Concretamente, lo que vamos a hacer es utilizar dos variables para almacenar el factor de incremento a aplicar en el movimiento de nuestro avión en cada vuelta del bucle del juego. Estas variables son deltaX y deltaY para el movimiento horizontal y vertical, respectivamente.
public void keyReleased(int keyCode) {
    int action=getGameAction(keyCode);

    switch (action) {

        case LEFT:
            deltaX=0;
            break;
        case RIGHT:
            deltaX=0;
            break;
        case UP:
            deltaY=0;
            break;
        case DOWN:
            deltaY=0;
            break;
    }
}
 
public void keyPressed(int keyCode) {
    int action=getGameAction(keyCode);

    switch (action) {

        case LEFT:
            deltaX=-5;
            break;
        case RIGHT:
            deltaX=5;
            break;
        case UP:
            deltaY=-5;
            break;
        case DOWN:
            deltaY=5;
            break;
    }
}
Cuando pulsamos una tecla, asignamos el valor correspondiente al desplazamiento deseado, por ejemplo, si queremos mover el avión a la derecha, el valor asignado a deltaX será 5. Esto significa que en cada vuelta del game loop, sumaremos 5 a la posición de avión, es decir, se desplazará 5 píxeles a la derecha. Cuando se suelta la tecla, inicializamos a 0 la variable, es decir, detenemos el movimiento.
La función encargada de calcular la nueva posición del avión es, pues, bastante sencilla.
void computePlayer() {
    // actualizar posición del avión
    if (hero.getX()+deltaX>0 && hero.getX()+deltaX
        hero.getY()+deltaY>0 && hero.getY()+deltaY

        hero.setX(hero.getX()+deltaX);
        hero.setY(hero.getY()+deltaY);
    }
}
Simplemente sumamos deltaX a la posición X del avión y deltaY a la posición Y. Antes comprobamos que el avión no sale de los límites de la pantalla.

Construyendo el mapa del juego

En nuestro juego vamos a utilizar una técnica basada en tiles para construir el mapa. La traducción de la palabra tile es baldosa o azulejo. Esto nos da una idea de en qué consiste la técnica: construir la imagen a mostrar en la pantalla mediante tiles de forma cuadrada, como si enlosáramos una pared. Mediante tiles distintos podemos formar cualquier imagen. La siguiente figura pertenece al juego Uridium, un juego de naves o shooter de los años 80 parecido al que vamos a desarrollar para ordenadores de 8 bits.

Las líneas rojas dividen los tiles empleados para construir la imagen.
En nuestro juego manejaremos mapas sencillos que van a estar compuestos por mosaicos de tiles simples. Algunos juegos tienen varios niveles de tiles (llamados capas). Por ahora, vamos a almacenar la información sobre nuestro mapa en un array de enteros tal como éste:
int map[]= {1,1,1,1,1,1,1,
            1,1,1,1,1,1,1,
            1,2,1,1,1,1,1,
            1,1,1,4,1,1,1,
            1,1,1,1,1,1,1,
            1,1,3,1,2,1,1,
            1,4,1,1,1,1,1};
Este array representa un mapa de 7x7 tiles. Vamos a utilizar los siguientes tiles, cada uno con un tamaño de 32x32 píxeles.

Para cargar y manejar los tiles nos apoyamos en la librería de manejo de sprites que desarrollamos en el capítulo anterior.
private Sprite[] tile=new Sprite[5];

// Inicializamos los tiles
for (i=1 ; i<=4 ; i++) {
    tile[i]=new Sprite(1);
    tile[i].on();
}

tile[1].addFrame(1,"/tile1.png");
tile[2].addFrame(1,"/tile2.png");
tile[3].addFrame(1,"/tile3.png");
tile[4].addFrame(1,"/tile4.png");
Hemos creado un array de sprites, uno por cada tile que vamos a cargar.
El proceso de representación del escenario consiste en ir leyendo el mapa y dibujar el sprite leído en la posición correspondiente. El siguiente código realiza este proceso:
// Dibujar fondo
for (i=0 ; i<7 ; i++) {
    for (j=0 ; j<7 ; j++) {
        t=map[i*xTiles+j];
        // calculo de la posición del tile
        x=j*32;
        y=(i-1)*32;

        // dibujamos el tile
        tile[t].setX(x);
        tile[t].setY(y);
        tile[t].draw(g);
    }
}
El mapa es de 7x7, así que los dos primero bucles se encargan de recorrer los tiles. La variable t, almacena el valor del tile en cada momento. El cálculo de la coordenada de la pantalla en la que debemos dibujar el tile tampoco es complicada. Al tener cada tile 32x32 píxeles, sólo hay que multiplicar por 32 el valor de los contadores i o j, correspondiente a los bucles, para obtener la coordenada de pantalla.

Scrolling

Con lo que hemos visto hasta ahora, somos capaces de controlar nuestro avión por la pantalla, y mostrar el fondo del juego mediante la técnica de los tiles que acabamos de ver. Pero un fondo estático no es demasiado atractivo, además, un mapa de 7X7 no da demasiado juego, necesitamos un mapa más grande. Para el caso de nuestro juego será de 7X20.
// Mapa del juego
int map[]= {1,1,1,1,1,1,1,
            1,1,1,1,1,1,1,
            1,2,1,1,1,1,1,
            1,1,1,4,1,1,1,
            1,1,1,1,1,1,1,
            1,1,3,1,2,1,1,
            1,1,1,1,1,1,1,
            1,4,1,1,1,1,1,
            1,1,1,1,3,1,1,
            1,1,1,1,1,1,1,
            1,4,1,1,1,1,1,
            1,1,1,3,1,1,1,
            1,1,1,1,1,1,1,
            1,1,1,1,1,1,1,
            1,2,1,1,1,1,1,
            1,1,1,4,1,1,1,
            1,1,1,1,1,1,1,
            1,1,3,1,2,1,1,
            1,1,1,1,1,1,1,
            1,4,1,1,1,1,1};
Se hace evidente que un mapa de 7x20 tiles no cabe en la pantalla. Lo lógico es que según se mueva nuestro avión por el escenario, el mapa avance en la misma dirección. Este desplazamiento del escenario se llama scrolling. Si nuestro avión avanza un tile en la pantalla hemos de dibujar el escenario pero desplazado (offset) un tile. Desafortunamente, haciendo esto exclusivamente veríamos como el escenario va dando saltitos, y lo que buscamos es un scroll suave. Necesitamos dos variables que nos indiquen a partir de que tile debemos dibujar el mapa en pantalla (la llamaremos indice) y otra variable que nos indique, dentro del tile actual, cuánto se ha desplazado (la llamaremos indice_in). Las variables xTile y yTile contendrán el número de tiles horizontales y verticales respectivamente que caben en pantalla. El siguiente fragmento de código cumple este cometido:
void doScroll() {

    // movimiento del escenario (scroll)
    indice_in+=2;
    if (indice_in>=32) {
        indice_in=0;
        indice-=xTiles;
    }

    if (indice <= 0) {
        // si llegamos al final, empezamos de nuevo.
        indice=map.length-(xTiles*yTiles);
        indice_in=0;
    }
}
El código para dibujar el fondo quedaría de la siguiente manera:
// Dibujar fondo
for (i=0 ; i
    for (j=0 ; j
        t=map[indice+(i*xTiles+j)];
        // calculo de la posición del tile
        x=j*32;
        y=(i-1)*32+indice_in;
        // dibujamos el tile
        tile[t].setX(x);
        tile[t].setY(y);
        tile[t].draw(g);
    }
}
Como diferencia encontramos dos nuevas variables. La variable indice contiene el desplazamiento (en bytes) a partir del cual se comienza a dibujar el mapa. La variable indice_in, es la encargada de realizar el scroll fino. Al dibujar el tile, se le suma a la coordenada Y el valor de la variable indice_in, que va aumentando en cada iteración (2 píxeles). Cuando esta variable alcanza el valor 32, es decir, la altura del tile, ponemos la variable a 0 y restamos el número de tiles horizonlates del mapa a la variable indice, o lo que es lo mismo, el offset a partir del que dibujamos el mapa. Se realiza una resta porque la lectura del mapa la hacemos de abajo a arriba (del último byte al primero). Recuerda que el mapa tiene 7 tiles de anchura, es por eso que restamos xTiles (que ha de valer 7) a la variable indice. Una vez que llegamos al principio del mapa, comenzamos de nuevo por el final, de forma que se va repitiendo el mismo mapa de forma indefinida. Vamos a suponer que el dispositivo es capaz de mostrar 8 líneas de tiles verticalmente (yTiles vale 8). Si puede mostrar menos, no hay problema alguno. El problema será que la pantalla pueda mostrar más, es decir, sea mayor de lo que hemos supuesto. En ese caso aumentaremos la variable yTiles. Un valor de 8 es lo suficientemente grande para la mayoría de los dispositivos. Ten cuenta que las primeras 8 filas del mapa tienen que ser iguales que las 8 últimas si no quieres notar un molesto salto cuando recomienza el recorrido del mapa.

import javax.microedition.midlet.*;
import javax.microedition.lcdui.*;

public class Scrolling extends MIDlet implements CommandListener {

    private Command exitCommand;
    private Display display;
    private SSCanvas screen;

    public Scrolling() {
        display=Display.getDisplay(this);
        exitCommand = new Command("Salir",Command.SCREEN,2);

        screen=new SSCanvas();

        screen.addCommand(exitCommand);
        screen.setCommandListener(this);
        new Thread(screen).start();
    }

    public void startApp() throws MIDletStateChangeException {
        display.setCurrent(screen);
    }

    public void pauseApp() {}

    public void destroyApp(boolean unconditional) {}

    public void commandAction(Command c, Displayable s) {

        if (c == exitCommand) {
            destroyApp(false);
            notifyDestroyed();
        }
    }
}
class SSCanvas extends Canvas implements Runnable {

    private int indice_in, indice, xTiles, yTiles, sleepTime;
    private int deltaX,deltaY;
    private Sprite hero=new Sprite(1);
    private Sprite[] tile=new Sprite[5];

    // Mapa del juego
    int map[] ={ 1,1,1,1,1,1,1,
                1,1,1,1,1,1,1,
                1,2,1,1,1,1,1,
                1,1,1,4,1,1,1,
                1,1,1,1,1,1,1,
                1,1,3,1,2,1,1,
                1,1,1,1,1,1,1,
                1,4,1,1,1,1,1,
                1,1,1,1,3,1,1,
                1,1,1,1,1,1,1,
                1,4,1,1,1,1,1,
                1,1,1,3,1,1,1,
                1,1,1,1,1,1,1,
                1,1,1,1,1,1,1,
                1,2,1,1,1,1,1,
                1,1,1,4,1,1,1,
                1,1,1,1,1,1,1,
                1,1,3,1,2,1,1,
                1,1,1,1,1,1,1,
                1,4,1,1,1,1,1};

    public SSCanvas() {
        // Cargamos los sprites
        hero.addFrame(1,"/hero.png");

        // Iniciamos los Sprites
        hero.on();
    }

    void iniciar() {

        int i;
        sleepTime = 50;
        hero.setX(getWidth()/2);
        hero.setY(getHeight()-20);
        deltaX=0;
        deltaY=0;
        xTiles=7;
        yTiles=8;
        indice=map.length-(xTiles*yTiles);
        indice_in=0;


        // Inicializamos los tiles
        for (i=1 ; i<=4 ; i++) {
            tile[i]=new Sprite(1);
            tile[i].on();
        }

        tile[1].addFrame(1,"/tile1.png");
        tile[2].addFrame(1,"/tile2.png");
        tile[3].addFrame(1,"/tile3.png");
        tile[4].addFrame(1,"/tile4.png");
    }

    void doScroll() {
   
        // movimiento del scenario (scroll)
        indice_in+=2;
        if (indice_in>=32) {
            indice_in=0;
            indice-=xTiles;
        }

        if (indice <= 0) {
            // si llegamos al final, empezamos de nuevo.
            indice=map.length-(xTiles*yTiles);
            indice_in=0;
        }
    }

    void computePlayer() {
        // actualizar posición del avión
        if (hero.getX()+deltaX>0 && hero.getX()+deltaX
            hero.getY()+deltaY>0 && hero.getY()+deltaY
                hero.setX(hero.getX()+deltaX);
                hero.setY(hero.getY()+deltaY);
        }
    }

    // thread que contiene el game loop 
    public void run() {
        iniciar();
        while (true) {

            // Actualizar fondo de pantalla
            doScroll();

            // Actualizar posición del jugador
            computePlayer();

            // Actualizar pantalla
            repaint();
            serviceRepaints();

            try {
                Thread.sleep(sleepTime);
            } catch (InterruptedException e) {
                System.out.println(e.toString());
            }
        }
    }   

    public void keyReleased(int keyCode) {
        int action=getGameAction(keyCode);

        switch (action) {

            case LEFT:
                deltaX=0;
                break;
            case RIGHT:
                deltaX=0;
                break;
            case UP:
                deltaY=0;
                break;
            case DOWN:        
                deltaY=0;
                break;
        }
    }

    public void keyPressed(int keyCode) {

        int action=getGameAction(keyCode);

        switch (action) {

            case LEFT:
                deltaX=-5;
                break;
            case RIGHT:
                deltaX=5;
                break;
            case UP:
                deltaY=-5;
                break;
            case DOWN:
                deltaY=5;
                break;
        }
    }

    public void paint(Graphics g) {

        int x=0,y=0,t=0;
        int i,j;

        g.setColor(255,255,255);
        g.fillRect(0,0,getWidth(),getHeight());
        g.setColor(200,200,0);

        // Dibujar fondo
        for (i=0 ; i
            for (j=0 ; j
                t=map[indice+(i*xTiles+j)];
                // calculo de la posición del tile
                x=j*32;
                y=(i-1)*32+indice_in;

                // dibujamos el tile
                tile[t].setX(x);
                tile[t].setY(y);
                tile[t].draw(g);
            }
        }

        // Dibujar el jugador
        hero.setX(hero.getX());
        hero.setY(hero.getY());
        hero.draw(g);
    }
}

No hay comentarios.:

Publicar un comentario