Juego de plataformas en Java (14): Mapas

En este punto se habrán dado cuenta que resulta muy difícil armar un mapa; puesto que tenemos que instanciar uno a uno los elementos del mapa (un mapa por nivel); y quedarían de forma estática.

Buscaremos resolver este problema para hacer mas flexible al juego y que pueda cargar la distribución de elementos (Box, Fruit, Enemys) desde un archivo, de manera que al tener varios archivos podamos manejar diferentes niveles.

Nos va a ayudar también a tener que modificar menos código cada vez que deseemos probar un nuevo elemento.

Para ir por partes el archivo solo contendrá el tipo de elemento (BOX, ENEMY, FRUIT), el tipo de cada uno y los puntos cartersianos (x,y); por ejemplo:

BOX,2,250,428
TRAPS,0,280,420

Nos tiene que devolver lo siguiente:

Una de las condiciones de la clase que se ocupe de leer el archivo y construir el mapa, es solo instanciar los elementos que esten dentro de la parte visible del mapa.

Comenzaremos definiendo una clase llamada Maps, que contendra lo siguiente:

package gsampallo;

import java.io.BufferedReader;
import java.io.FileReader;
import java.util.ArrayList;
import java.awt.Point;

public class Maps {

    private int mapNumber;

    private int lastX = 0;
    private int lastY = 0;

    public Maps(int mapNumber) {
        this.mapNumber = mapNumber; 
    }
}

El constructor de la clase recibe un valor entero que nos indicara cual es el archivo que debemos cargar. lastX y lastY son los últimos valores del mapa que fueron cargados, todo lo menor a lastX y lastY no debe ser considerado al momento de volver a cargar el mapa.

Con esto establecido, definimos un nuevo método llamado loadMap(), que va a recibir como parámetros las listas de elementos de cada tipo, de manera de añadir a la lista lo que corresponda.

    public void loadMap(ArrayList<Box> listBox,ArrayList<Fruit> listFruit,ArrayList<Enemy> listEnemy,ArrayList<Traps> listTraps) {
        String path = "maps/map."+mapNumber;

        try {
            BufferedReader br = new BufferedReader(new FileReader(path));

            String line = br.readLine();
    
            while (line != null) {

                String[] data = line.split(",");
                int type = (int)Integer.valueOf(data[1]).intValue();
                int x = (int)Integer.valueOf(data[2]).intValue();
                int y = (int)Integer.valueOf(data[3]).intValue();

                if((x > lastX) || (x == lastX && y < lastY)) {

                    lastX = x;
                    lastY = y;

                    if(data[0].equals("BOX")) {
                        //System.out.println("BOX");

                        Box box = new Box(type,new Point(x,y));
                        listBox.add(box);

                    }

                }
                
                line = br.readLine();
            }
            
        } catch(Exception e) {

            System.err.println(e.getMessage());
        }
    }

En el método anterior solo especificamos para el tipo de elemento BOX, para hacerlo más sencillo de comprender. Leemos cada linea del archivo de texto plano; lo dividimos según donde estén ubicadas las comas; esto nos devuelve un array y lusto convertimos cada parte del array en su correspondiente elemento.

Finalmente solamente evaluamos si data[0] el primer elemento del array es igual a BOX, TRAPS, etc. y en consecuencia instanciamos el elemento correspondiente y lo integramos a la lista.

Creamos una carpeta llamada maps y dentro un archivo llamada map.1:

BOX,1,200,428
BOX,1,228,428
BOX,1,256,428
BOX,1,284,428
BOX,1,704,428
BOX,1,732,428
BOX,1,760,428

Por el momento solo trabajaremos con BOX, pero podemos extender al resto de los elementos sin problemas. Antes de seguir viendo como lo extendemos a los otros componentes (en particular Enemy) veremos como integrarlo con RunnerOne.

Incorporaremos algunas variables a RunnerOne para el manejo del mapa:

    private int increaseMap = 10;
    private Maps maps;
    private int mapNumber = 1;
    private int relativeGamePosition = 0;

Separaremos del constructor de RunnerOne, donde definimos las dimensiones del Frame y la posicion de los elementos del juego, creando un metodo llamada initGame():

    public void initGame() {
        /*
         * Background
         */
        background = new Background("image/bgd1.png");

        /*
         * Player
         */
        player = new Player(Player.MASK_DUDE,new Point(50,410));

	/*
	 * MAPS
	 */
	map = new Maps(mapNumber);
	increaseMap = player.width;
        
        /*
         * Credits
         */
        showCredits = new Credits();

        /*
         * FRUIT
         */
        listFruit = new ArrayList<Fruit>();

        /*
         * BOX
         */
        listBox = new ArrayList<Box>();

        /*
         * Weapons
         */
        listWeapon = new ArrayList<Weapon>();

        /*
         * Traps
         */
        listTraps = new ArrayList<Trap>();

        /*
         * Enemys
         */
        listEnemy = new ArrayList<Enemy>();

		//Timer
		timer = new Timer(DELAY, this);
		timer.start();
    }

Dentro de RunnerOne también incorporaremos un nuevo metodo llamado loadGameItems(), será el encargado de llamar a Maps y solicitarle que cargue los elementos del juego:

	private void loadGameItems() {

		map.loadMap(listBox,listFruit,listEnemy,listTraps);

	}

Uno de las especificaciones que hicimos fue que el mapa se cargue en memoria de manera gradual; para ello haremos uso de las variables que definimos previamente relativeGamePosition y increaseMap; basicamente cada vez que lleguemos a un punto determinado de avance establecido por increaseMap invocaremos al metodo loadGameItems().

Esta tarea se realiza desde actionPerformed, lo modificamos de la siguiente manera:

    public void actionPerformed(ActionEvent e) {

        if(relativeGamePosition < increaseMap) {
			relativeGamePosition++;
		} else {
			relativeGamePosition = 0;
			loadGameItems();
		}

        updateGame();

        repaint();
        
    }

Modificaremos el constructor para que llame a initGame() e inicialice los parámetros del juego:

public RunnerOne() {

  /** resto del codigo **/
  initGame();

  setVisible(true);

}

Si todo sale como espero, cuando compilemos y ejecutemos el juego veremos lo siguiente:

Juego de plataformas en Java (13): Aún más enemigos

Anteriormente incorporamos a Rino, un rinoceronte que en determinadas circunstancias sale a patrullar una franja del mapa. En esta ocasión integraremos un nuevo enemigo al juego: Trunk. Un bicho, por no encontrar mejor denominación, que en ciertas oportunidades dispara una munición.

De la misma forma que hicimos con Angry Pig y Rino, crearemos una nueva clase llamada Trunk que extienda de Enemy e implemente los métodos elementales necesarios:

package gsampallo.enemys;

import java.awt.Point;
import java.awt.image.BufferedImage;
import java.io.File;

import javax.imageio.ImageIO;

public class Trunk extends Enemy {
    
    public static int STATE_ATTACK = 7;

    public Trunk(Point initialPoint) {
        super(Enemy.ENEMY_TRUNK,initialPoint);

        this.width = 64;
        this.height = 32;

        loadImages();

        lifePoint = 2;
    }

    protected BufferedImage imageRun;
    protected BufferedImage imageIdle;
    protected BufferedImage imageAttack;
    protected BufferedImage imageHit;
    protected BufferedImage imageBullet;

    protected void loadImages() {

        String pathRun = "image/Enemies/Trunk/Run (64x32).png";
        String pathIdle = "image/Enemies/Trunk/Idle (64x32).png";
        String pathHit = "image/Enemies/Trunk/Hit (64x32).png";
        String pathAttack = "image/Enemies/Trunk/Attack (64x32).png";
        String pathBullet = "image/Enemies/Trunk/Bullet.png";

        try {

            imageRun = ImageIO.read(new File(pathRun));
            imageIdle = ImageIO.read(new File(pathIdle));
            imageAttack = ImageIO.read(new File(pathAttack));
            imageBullet = ImageIO.read(new File(pathBullet));
            imageHit = ImageIO.read(new File(pathHit));

        } catch (Exception e) {
            System.err.println("No se pudieron cargar imagenes de Trunk");
            System.err.println(e.getMessage());
            System.exit(0);
        }
    } 

Si los medodos updateEnemy y getImage() los copiamos tal cual desde Angry Pig, ya tendríamos a nuestro enemigo Trunk con el mismo comportamiento que Angry Pig, solo que reemplaza las imágenes, no es lo ideal, queremos que tenga su propio caracter.

Igual que con Rino, integraremos Decisions en el constructor para que pueda tomar decisiones en función de un valor aleatorio; con esto dadas ciertos eventos decidirá si dispara o si realiza un patrullaje igual que Rino.

Uno de los cambios que realizaremos sobre Trunk, es que al recibir un Hit, automáticamente cambiara su estado a ATTACK, y devolvera el ataque con tres disparos.

    public void hit() {
        lifePoint--;
        changeState(STATE_HIT);
        imageNumber = 4;
        
    }

Una de las características de Trunk es que puede disparar pequeños bloques de madera, a los que llamaremos Bullet (por el nombre del archivo de la imagen); estos Bullets tendrán un comportamiento similar a las sierras que lanza nuestro protagonista. Crearemos entonces una clase llamada Bullet que extiende de Weapon:

package gsampallo.enemys;

import java.awt.Point;
import java.awt.image.BufferedImage;
import java.io.File;

import javax.imageio.ImageIO;
import gsampallo.Weapon;

public class Bullet extends Weapon  {

    public Bullet(Point initialPosition) {
        super(initialPosition);

        this.width = 16;
        this.height = 16;
        friendlyFire = false;
        loadImages();
    }

    private void loadImages() {

        try {

            imageWeapon = ImageIO.read(new File("image/Enemies/Trunk/Bullet.png"));

        } catch (Exception e) {
            System.err.println("No se pudieron cargar imagenes de Bullet");
            System.err.println(e.getMessage());
        }
    }

    public void updateWeapon(boolean move) {     
        position.x = position.x - 3;
        visible = visible && (position.x > 0);
    }

    public BufferedImage getImage() {

        return imageWeapon;
    }   

    public int getX() {
        return this.position.x;
    }

    public int getY() {
        return this.position.y;
    }
    public int getWidth() {
        return this.width;
    }
    
    public int getHeight() {
        return this.height;
    }
     
    private boolean visible = true;

    public boolean isVisible(){
        return visible;
    }

    public void setVisible(boolean visible) {
        this.visible = visible;
    }

}

De momento solo haremos que Trunk dispare estos Bullet cuando recibe un Hit; será necesario establecer una «bandera» dentro de Trunk que nos indique realizo un nuevo disparo, para ello integramos lo siguiente:

private boolean fired = false;
public boolean shotFire() {
  return fired;
}

Como es una sola imagen y no un sprite, el método getImage() devuelve la imagen. Los Bullets viajan en sentido opuesto a Weapons, es decir hacia el personaje; con lo cual en método updateWeapon(), disminuiremos el valor de x en lugar de sumarlo; esto es para que se acerquen al margen izquierdo de la ventana.

Volviendo a la clase Trunk, nos quedo pendiente definir updateEnemy():

   public void updateEnemy(boolean move) {  
        

        if(state == STATE_RUN) {
            position.x = position.x - 2;
            if(imageNumber < (imageRun.getWidth()/width)-1) {
                imageNumber++;
            } else {
                imageNumber = 0;
            }


        } else if(state == STATE_ATTACK) {

            if(imageNumber < (imageAttack.getWidth()/width)-1) {
                fired = false;
                imageNumber++;
            } else {
                imageNumber = 0;
                if(nFire > 0) {
                    nFire--;
                }
                changeState(STATE_IDLE);
            }
        

        } else if(state == STATE_HIT) {
            if(imageNumber > 0) {
                imageNumber--;
            } else {
                imageNumber = 0;
                if(lifePoint == 0) {
                    visible = false;
                } else {
                    if(previousState != STATE_ATTACK) {
                        initFire();
                    }
                    
                }
            }
        } else {
            /*
             * IDLE
             */
            if(imageNumber < (imageIdle.getWidth()/width)-1) {
                imageNumber++;
            } else {
                imageNumber = 0;
                if(nFire > 0) {
                    shootFire();
                    changeState(STATE_ATTACK);
                }
            }        
        }

        if(move) {
            position.x--;

            visible = (position.x > 0) && visible;
        }
    }

También debemos definir el método initFire() y algunas variables:

    private int nFire = 0;
    private int previousState = 0;
    private int nPatrol = 0;
    private int nIdle = 0;

    private boolean fired = false;

    public void initFire() { 

        nFire = 3; // Will shot three bullets

        System.out.println("Show fire! "+nFire);

        changeState(STATE_ATTACK);
        shootFire();
    }

initFire() será invocado luego que finaliza el ciclo de las imágenes de Hit. La variable nFire define la cantidad de disparos que realizara; entra disparo y disparo cambia al estado IDLE para dar un tiempo intermedio.

Finalmente en RunnerOne, modificamos el método updateGame(), específicamente el bloque de código destinado a actualizar enemigos:

public void updateGame() {

  /** resto del codigo **/
        if(!listEnemy.isEmpty()) {
            Iterator it = listEnemy.iterator();
            while(it.hasNext()) {
                Enemy enemy = (Enemy)it.next();

                enemy.updateEnemy(moved);
                
                if(enemy.isVisible()) {  

                    if(enemy.getType() == Enemy.ENEMY_RINO) {
                        rinoBoxes((Rino)enemy);
                    }

                    if(enemy.getType() == Enemy.ENEMY_TRUNK) {
                        updateTrunk((Trunk)enemy);
                    }

                } else {      
                    it.remove();
                }
            }
        }

}

En caso que el tipo de enemigo sea Trunk invocamos al método updateTrunk(), esto nos ayuda a no tener un bloque de código tan extenso y dificil de leer.

    private void updateTrunk(Trunk trunk) {
        if(trunk.shotFire()) {
            System.out.println("Show fire");
            Bullet bullet = new Bullet(new Point(trunk.getX(),trunk.getY()+10));
            listWeapon.add(bullet);
        }
    }

El metodo updateTrunk() simplemente determina si se realizo algún disparo durante esa iteración, y de ser así, crea una instancia de Bullet en un punto del plano relativo al enemigo y lo agrega a la lista de Weapons; recordemos que Bullet extiende de Weapon y puede ser utilizada de esa manera.

Si será necesario incorporar un pequeño bloque de codigo en Weapon, para determinar si los disparos son amigos o no, con esto evitamos que los disparos enemigos maten a otros enemigos:

    protected boolean friendlyFire = true;

    public boolean isFriendlyFire() {
        return friendlyFire;
    }

En consecuencia actualizamos el bloque de código de los disparos dentro de updateGame():

public void updateGame() {

  /** resto del codigo **/

  if(!listWeapon.isEmpty()) {
    Iterator it = listWeapon.iterator();
    while(it.hasNext()) {
      Weapon weapon = (Weapon)it.next();
      weapon.updateWeapon(moved);
      if(weapon.isVisible()) {

        if(weapon.isFriendlyFire()) {  // esta es la condicion que se agrega

          /** resto del codigo donde se recorren las listas **/

        } else {
          //aqui es donde debemos ver si impacta al personaje y actuar en consecuencia
        }
     }

  
  /** resto del codigo **/

}