Creación de un juego (5): Modulo Graficos

Este modulo es algo mas complejo de los que llevamos creados hasta ahora. Y sera el responsable de cargar imágenes (bitmaps) en nuestras pantallas. En principio si queremos gráficos de alto rendimiento primero deberemos saber por lo menos los fundamentos de la programación gráfica y para ello vamos a conocer los conceptos básicos en gráficos 2D. Crearemos dos interfaces y tres clases para manejar los gráficos de nuestro juego.

Para llevar a cabo esta tarea deberemos de ser capaces de realizar las siguientes operaciones:
Almacenar imágenes en la RAM para su posterior uso.
Limpiar nuestra pantalla de todos los elementos que la componen.
Pintar un pixel de la pantalla con un color especifico.
Dibujar lineas y rectángulos en nuestra pantalla.
Dibujar imágenes en la pantalla cargadas previamente. Ya sea una imagen completa o una porción de la misma. También dibujar imágenes con y sin mezcla.
Obtener el tamaño de nuestra pantalla de dibujo.


1.1 Interface Pixmap
import com.example.slange.interfaces.Graphics.PixmapFormat;

public interface Pixmap {
    
    public int getWidth();

    public int getHeight();

    public PixmapFormat getFormat();

    public void dispose();
}
Primero importamos la interface Gráficos para tener acceso a su miembro PixmapFormat, se explicara en el siguiente punto del articulo.

Esta interface nos permitirá conocer el tamaño en píxeles de una imagen, a través de los métodos getWidth y getHeight. También podremos conoces su formato con el método getFormat. Y para terminar, con el método dispose liberaremos las imágenes cargadas en la memoria.


1.2 Interface Gráficos
public interface Graphics {
    
    public static enum PixmapFormat {
        ARGB8888, ARGB4444, RGB565
    }

    public Pixmap newPixmap(String fileName, PixmapFormat format);

    public void clear(int color);

    public void drawPixel(int x, int y, int color);

    public void drawLine(int x, int y, int x2, int y2, int color);

    public void drawRect(int x, int y, int width, int height, int color);

    public void drawPixmap(Pixmap pixmap, int x, int y, int srcX, int srcY,
            int srcWidth, int srcHeight);

    public void drawPixmap(Pixmap pixmap, int x, int y);

    public int getWidth();

    public int getHeight();
}
Interface que nos servirá para dibujar en pantalla tanto imágenes como píxeles a parte de poder recuperar el tamaño de nuestra pantalla de dibujo (framebuffer).

Primero creamos un enum PixmapFormat donde almacenamos 3 tipos de formato de imagen. Los enum sirven para restringir el contenido de una variable (en nuestro caso PixmapFormat) a una serie de valores predefinidos (ARGB8888, ARGB4444, RGB565), es decir, nuestra variable PixmapFormat solo tienes las 3 posibilidades, esto suele ayudar a reducir los errores de nuestro código. Esta variable la usaremos para indicar el tipo de formato de imagen que necesitemos, consiguiendo almacenar la imagen en un tamaño menor o mayor:
ARGB444: cada pixel de la imagen se almacena en 2 bytes y los tres canales de color RGB mas el canal alpha (A) se almacenan con una precisión de 4 bits (16 posibilidades). Útil cuando queremos usar el menor almacenamiento para nuestras imágenes, pero se recomienda usar ARGB888.
ARGB888: cada pixel se almacena en 4 bytes. Cada canal ARGB se almacena con una precisión de 8 bits (256 posibilidades). Esta configuración es muy flexible y ofrece la mejor calidad pero mayor tamaño de almacenamiento.
RGB565: cada pixel se almacena en 2 bytes y solo disponemos de los canales RGB. el rojo se almacena con 5 bits de precisión (32 posibilidades), el verde con 6 bits (64 posibilidades) y el azul con 5 bits. Esta configuración es útil cuando se usan imágenes opacas que no requieren alta definición de color.
Continuamos creando un método newPixmap que nos devolverá un objeto Pixmap y como parámetros indicaremos el nombre del archivo de imagen y el formato que necesitemos darle.

Los cuatro siguientes metodos (clear, drawPixel, drawLine, drawRect) los usaremos para colorear píxeles con un color especifico y de varias formas posibles (toda la pantalla, un solo pixel, una linea o un rectángulo).

Después tenemos dos métodos drawPixmap que nos ayudaran a dibujar en pantalla una imagen o una porción de imagen en el sitio que le indiquemos de nuestra pantalla de dibujo (framebuffer).

Y por ultimo dos métodos que nos devolverán el ancho y el alto de la pantalla de dibujo (framebuffer).


2.1 Implementar interface Pixmap
import android.graphics.Bitmap;

import com.example.slange.interfaces.Graphics.PixmapFormat;
import com.example.slange.interfaces.Pixmap;

public class AndroidPixmap implements Pixmap {
    
    Bitmap bitmap;
    PixmapFormat format;
    
    public AndroidPixmap(Bitmap bitmap, PixmapFormat format) {
        this.bitmap = bitmap;
        this.format = format;
    }
A parte de implementar la interface Pixmap, importamos a esta clase el enum PixmapFormat de la interface Gráficos.

Empezamos creando dos objetos: un Bitmap (que nos ayudara a trabajar con archivos de mapa de bits, es decir, imágenes) y un PixmapFormat  (que usaremos para determinar que tipo de formato le queremos dar a la imagen).
En el constructor simplemente almacenamos los dos parámetros que le pasamos al constructor, en los dos objetos que hemos creado para esta clase.

    public int getWidth() {
        return bitmap.getWidth();
    }

    public int getHeight() {
        return bitmap.getHeight();
    }
Aplicando los métodos getWidth y getHeight nos devolverá el ancho y alto de nuestra imagen bitmap.

    public PixmapFormat getFormat() {
        return format;
    }

    public void dispose() {
        bitmap.recycle();
    }      
}
El método getFormat nos devolverá un PixmapFormat. El formato de nuestra imagen bitmap.
Y para terminar con el método dispose liberaremos de la memoria la imagen asociada al bitmap gracias al método recycle de la clase Bitmap.


2.2 Implementar interface Gráficos
import java.io.IOException;
import java.io.InputStream;

import android.content.res.AssetManager;
import android.graphics.Bitmap;
import android.graphics.Bitmap.Config;
import android.graphics.BitmapFactory;
import android.graphics.BitmapFactory.Options;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Paint.Style;
import android.graphics.Rect;

import com.example.slange.interfaces.Graphics;
import com.example.slange.interfaces.Pixmap;

public class AndroidGraphics implements Graphics {
    
    AssetManager assets;
    Bitmap frameBuffer;
    Canvas canvas;
    Paint paint;
    Rect srcRect = new Rect();
    Rect dstRect = new Rect();
A parte de implementar la interface Graficos, importamos también la interface Pixmap ya que usaremos algún atributo suyo en esta clase.
Empezamos creando un objeto AssetManager que a estas alturas ya sabremos lo que es.
Creamos un objeto Bitmap que como iba indicando pasos atrás sera nuestra pantalla de dibujo (framebuffer) donde colocaremos los distintos elementos de nuestras pantallas del juego.
Otro objeto Canvas que atenderá a las llamadas de dibujo, es decir, dibujara píxeles o imágenes en nuestro framebuffer.
Un objeto Paint que sera el encargado de darle estilo y color a lo que dibujemos en nuestro framebuffer.
Y por ultimo creamos dos objetos Rect: srcRect se encargara de almacenar las cuatro coordenadas de un rectángulo (que serán las 4 esquinas de nuestras imágenes) y dstRect se encargara de dibujar esas imágenes en las coordenadas que le indiquemos.

    public AndroidGraphics(AssetManager assets, Bitmap frameBuffer) {
        this.assets = assets;
        this.frameBuffer = frameBuffer;
        this.canvas = new Canvas(frameBuffer);
        this.paint = new Paint();
    }
Almacenamos los parámetros del constructor de la clase en sus respectivas variables de la clase.
Iniciamos el objeto canvas indicando como parámetro la pantalla donde podrá dibujar.
Y terminamos iniciando el objeto Paint.

    public Pixmap newPixmap(String fileName, PixmapFormat format) {
        Config config = null;
        if (format == PixmapFormat.RGB565)
            config = Config.RGB_565;
        else if (format == PixmapFormat.ARGB4444)
            config = Config.ARGB_4444;
        else
            config = Config.ARGB_8888;

        Options options = new Options();
        options.inPreferredConfig = config;

        InputStream in = null;
        Bitmap bitmap = null;
        try {
            in = assets.open(fileName);
            bitmap = BitmapFactory.decodeStream(in);
            if (bitmap == null)
                throw new RuntimeException("Error al cargar bitmap desde assets '"
                        + fileName + "'");
        } catch (IOException e) {
            throw new RuntimeException("Error al cargar bitmap desde assets '"
                    + fileName + "'");
        } finally {
            if (in != null) {
                try {
                    in.close();
                } catch (IOException e) {
                }
            }
        }

        if (bitmap.getConfig() == Config.RGB_565)
            format = PixmapFormat.RGB565;
        else if (bitmap.getConfig() == Config.ARGB_4444)
            format = PixmapFormat.ARGB4444;
        else
            format = PixmapFormat.ARGB8888;

        return new AndroidPixmap(bitmap, format);
    }
El método newPixmap intentara cargar una imagen desde la carpeta assets. Parece muy complicado pero es mas fácil de lo que aparenta.

Empezamos creando un objeto Config que tiene los mismos tipos de formato que nuestro objeto PixmapFormat. Comprobaremos en que formato (ARGB444, ARGB888, RGB565) viene nuestra imagen y lo almacenaremos en nuestro objeto config.
Seguidamente creamos un objeto Options para almacenar el tipo de formato config preferido. Este objeto options se encargara automáticamente de establecer este formato si es posible.

Intentamos cargar una imagen desde la carpeta assets al objeto bitmap a través del método decodeStream de la clase BitmapFactory que nos pide como parámetro una fuente de datos a leer. Manejaremos la IOException en caso de que ocurra algo y comprobaremos que nuestro objeto bitmap es nulo por si acaso. Finalmente si nuestro InputStream no es nulo, lo cerramos.

Tras cargar la imagen en nuestro bitmap, el BitmapFactory podría hacer caso omiso del tipo de formato de nuestra imagen por lo que tenemos que volver a comprobarlo y almacenarlo en nuestro parámetro format.

Para terminar devolvemos un nuevo objeto AndroidPixmap indicando los parámetros que hemos recogido.

    public void clear(int color) {
        canvas.drawRGB((color & 0xff0000) >> 16, (color & 0xff00) >> 8,
                (color & 0xff));
    }
Con este método podremos pintar toda la pantalla de nuestro framebuffer del color que se le indique como parámetro, para ello haremos uso del método drawRGB que nos pide como parámetro un rango de 0 a 255 para cada color primario(rojo, verde, azul).

    public void drawPixel(int x, int y, int color) {
        paint.setColor(color);
        canvas.drawPoint(x, y, paint);
    }
Podremos pintar un pixel con el siguiente método, indicando como parámetro las coordenadas x e y de nuestro framebuffer. Como parámetro color indicaremos el estilo de color a nuestro objeto paint y realizaremos el dibujo con el método drawPoint.

    public void drawLine(int x, int y, int x2, int y2, int color) {
        paint.setColor(color);
        canvas.drawLine(x, y, x2, y2, paint);
    }
Pintaremos una linea gracias a este método, indicando las coordenadas de inicio (x, y) y las coordenadas de destino (x2, y2). Para ello hacemos uso del método drawLine de la clase Canvas.

    public void drawRect(int x, int y, int width, int height, int color) {
        paint.setColor(color);
        paint.setStyle(Style.FILL);
        canvas.drawRect(x, y, x + width - 1, y + width - 1, paint);
    }
Comentar aquí que las coordenadas 0, 0 de x e y de nuestro terminal, se encuentran en la esquina superior izquierda de la pantalla (por lo tanto empezaremos a sumar hacia abajo y hacia la derecha).
Sabiendo esto, con el siguiente método podremos pintar un rectángulo con el color que se le indique. El inicio del rectángulo y por lo tanto su esquina superior izquierda la declararemos con los parámetros x e y. La altura y anchura lo haremos con height y width.
Si nos fijamos le estamos restando -1 a la altura y anchura porque si declaráramos un rectángulo de 5x5, el método drawRect coje esos dos píxeles de altura o anchura y le suma el punto de partida con lo cual se con quedaría un rectángulo de 6x6.

    public void drawPixmap(Pixmap pixmap, int x, int y, int srcX, int srcY,
            int srcWidth, int srcHeight) {
        
        srcRect.left = srcX;
        srcRect.top = srcY;
        srcRect.right = srcX + srcWidth - 1;
        srcRect.bottom = srcY + srcHeight - 1;

        dstRect.left = x;
        dstRect.top = y;
        dstRect.right = x + srcWidth - 1;
        dstRect.bottom = y + srcHeight - 1;

        canvas.drawBitmap(((AndroidPixmap) pixmap).bitmap, srcRect, dstRect, null);
    }
Con este método conseguiremos pintar en pantalla una porción de imagen y ocurre los mismo que en la pantalla, las coordenadas 0,0 de x e y de una imagen se encuentran en la esquina superior izquierda.

Primero deberemos seleccionar la porción de imagen indicando su esquina superior izquierda con los parámetros (srcX y srcY), para el ancho usaremos (srcWidth) y para el alto (srcHeight).
Una vez seleccionada el método drawPixmap almacena estos datos en el objeto srcRect.

Y para indicar donde la queremos pintar en nuestro framebuffer usaremos los parámetro (x, y), el propio método sabiendo la esquina superior izquierda, calculara las cuatro coordenadas y las almacenara en el objeto dstRect.

Recordar que le restamos -1 ya que sino la imagen excederá en un pixel.

Finalmente podemos pintar esa porción de imagen con el método drawBitmap de la clase Canvas.

    public void drawPixmap(Pixmap pixmap, int x, int y) {
        canvas.drawBitmap(((AndroidPixmap)pixmap).bitmap, x, y, null);
    }
Este método es similar al anterior pero mucho mas sencillo, con el simplemente dibujaremos en pantalla una imagen completa en las coordenada (x, y) que se le indique.

    public int getWidth() {
        return frameBuffer.getWidth();
    }

    public int getHeight() {
        return frameBuffer.getHeight();
    }
}
Para terminar la clase sobreescribimos los métodos detWidth y getHeight que nos devolverán el tamaño de pantalla de dibujo.


2.3 Clase FastRenderView

Podríamos decir que esta clase sera la encargada de pintar nuestro framebuffer en la pantalla de nuestro terminal, colocando el framebuffer en su lugar correspondiente y escalándolo a los diferentes tamaños de pantalla si es necesario. También nos permitirá conocer la pantalla activa del juego y podremos controlar el DeltaTime en todo momento.

El DeltaTime es un concepto que se utiliza en programación que relaciona el hardware y su capacidad de respuesta. Y cuando hablamos de mover gráficos el deltatime se calcula llamando a un temporizador que controlara los fotogramas por segundo del hardware y cogerá el tiempo que pasa entre que se ve un fotograma y el siguiente, esto nos servirá para mostrar la misma cantidad de cuadros por segundo.

A continuación vamos a ir viendo la clase por partes, explicando su metodología:

import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

public class AndroidFastRenderView extends SurfaceView implements Runnable {
    
    AndroidGame game;
    Bitmap framebuffer;
    Thread renderThread = null;
    SurfaceHolder holder;
    volatile boolean running = false;
Extendemos la clase SurfaceView que nos proporciona una superficie de dibujo dentro de una view, a parte implementamos la interface Runnable que nos servirá para manejar hilos secundarios, es decir, podremos ejecutar código en un hilo diferente al principal.Esta implementación nos pide sobreescribir el método run.

Creamos una instancia de la clase AndroidGame (en el articulo 6 veremos el modulo juego y de que trata esta clase). Y un objeto Bitmap que sera nuestra SurfaceView.

Continuamos creando un objeto Thread que usaremos para crear hilos nuevos o eliminar los existentes. Un thread se puede definir como una unidad de código en ejecución.

El objeto SurfaceHolder nos permitirá controlar el tamaño y formato de la superficie de dibujo, editar los píxeles y vigilar los cambios en dicha superficie. Esta interface esta disponible a través de la clase SurfaceView.

Y por ultimo creamos una variable booleana donde almacenaremos si un hilo debe ser detenido o reanudado. Con el modificador volatile indicamos que dicha variable puede ser modificada por varios hilos (threads) de forma simultanea y asíncrona, asegurando así su valor en todo momento a costa de un pequeño impacto en el rendimiento.

    public AndroidFastRenderView(AndroidGame game, Bitmap framebuffer) {
        super(game);
        this.game = game;
        this.framebuffer = framebuffer;
        this.holder = getHolder();
    }
Con el modificador super estamos llamando al constructor de la clase AndroidGame a través del parámetro game (AndroidGame se trata de una Activity que veremos en el capitulo siguiente). Almacenamos también nuestro parámetro en la variable game de la clase.

También almacenamos el segundo parámetro en su variable framebuffer. Y en la variable holder usamos el método getHolder que nos devuelve una instancia SurfaceHolder que almacenamos también.

    public void resume() { 
        running = true;
        renderThread = new Thread(this);
        renderThread.start();         
    }
Método que usaremos para crear nuevos hilos. En cada hilo pondremos su variable running a true indicando que ese hilo esta en ejecución.
Iniciamos un nuevo hilo y lo ejecutamos con el método start (que este a su vez hará una llamada al método run para el hilo creado).
Con este método resume nos aseguramos de que nuestro nuevo hilo actúa bien con el ciclo de vida de la Activity.

    public void run() {
        Rect dstRect = new Rect();
        long startTime = System.nanoTime();
        while(running) {  
            if(!holder.getSurface().isValid())
                continue;           
            
            float deltaTime = (System.nanoTime()-startTime) / 1000000000.0f;
            startTime = System.nanoTime();

            game.getCurrentScreen().update(deltaTime);
            game.getCurrentScreen().present(deltaTime);
            
            Canvas canvas = holder.lockCanvas();
            canvas.getClipBounds(dstRect);
            canvas.drawBitmap(framebuffer, null, dstRect, null);
            holder.unlockCanvasAndPost(canvas);
        }
    }
Este método sera llamado cada vez que se cree un nuevo hilo o pantalla de juego. Y sera el encargado de actualizar/renderizar los objetos en la pantalla en cada momento. Se ira repitiendo continuamente en la pantalla que lo llame.

Empezamos creando un objeto Rect que nos servirá para almacenar los limites de la pantalla. Y una variable startTime donde almacenaremos la hora actual en nanosegundos. Un nanosegundo es una mil millonésima de segundo.

Seguimos creando un bucle while que se repetirá mientras nuestro hilo este en ejecución. Lo primero que hacemos dentro del hilo es comprobar si existe una superficie valida gracias al bloque if, si no existe una superficie valida entra en juego continue que hace terminar el bucle.
Si existe una superficie valida calculamos el DeltaTime, lo convertimos a segundos y seguidamente almacenamos de nuevo la hora actual.

Una vez calculado el DeltaTime usamos los métodos update y present para ir actualizando la pantalla actual con el intervalo de tiempo DeltaTime. (en el articulo 6 veremos estos métodos en detalle)

Por ultimo creamos un objeto canvas al que le indicamos con el método lockCanvas que puede comenzar la edición de píxeles en la superficie de dibujo. Con el método getClipBounds recuperamos los limites de la superficie de dibujo. Y ya con el método drawBitmap haremos que pinte una pantalla del juego. Para finalizar llamamos al método unlockCanvasAndPost para terminar la edición de píxeles y conseguir mostrar una pantalla de nuestro juego.

    public void pause() {                        
        running = false;                        
        while(true) {
            try {
                renderThread.join();
                return;
            } catch (InterruptedException e) {
                // retry
            }
        }
    } 
Con el método pause bloquearemos el hilo en ejecución a la espera de que el usuario lo finalice y termine por desaparecer de la memoria.


CODIGO DE EJEMPLO: DESCARGAR

No hay comentarios:

Publicar un comentario