Juego de plataformas en Java (5): Frutas

Debemos ir completando el juego con otros elementos ademas de nuestro personaje; para ello incorporaremos las frutas, que el personaje podrá ir recolectando para sumar puntos.

La idea es crear una clase genérica Fruit, que a partir de un parámetro en el constructor instance una u otra fruta. También crearemos una interface llamada Element, que nos servirá para determinar si el jugador pudo recolectar las frutas o no, y que utilizaremos más adelante en el juego también:

package gsampallo;

public interface Element {
    public int getX();
    public int getY();
    public int getWidth();
    public int getHeight();
}

Creamos también una clase llamada Fruit que implementara la interface Element:

package gsampallo;

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

import javax.imageio.ImageIO;

public class Fruit implements Element {

    public static int APPLE = 0;
    public static int BANANAS = 1;
    public static int CHERRYS = 2;
    public static int KIWI = 3;
    public static int MELON = 4;
    public static int ORANGE = 5;
    public static int PINEAPPLE = 6;
    public static int STRAWBERRY = 7;

    private String[] imagesPath = {
        "image/Items/Fruits/Apple.png",
        "image/Items/Fruits/Bananas.png",
        "image/Items/Fruits/Cherries.png",
        "image/Items/Fruits/Kiwi.png",
        "image/Items/Fruits/Melon.png",
        "image/Items/Fruits/Orange.png",
        "image/Items/Fruits/Pineapple.png",
        "image/Items/Fruits/Strawberry.png"

    };

    private int width = 32;
    private int height = 32;

    private int fruitNumber;
    private Point position;

    private int creditsValue = 1;

    public Fruit(int fruitNumber,Point initialPosition) {
        this.fruitNumber = fruitNumber;
        this.position = initialPosition;

        this.creditsValue = fruitNumber + 1;

        loadImages();
    }

    private BufferedImage imageFruit;
    private BufferedImage imageCollected;

    private void loadImages() {
        try {

            imageFruit = ImageIO.read(new File(imagesPath[this.fruitNumber]));
            imageCollected = ImageIO.read(new File("image/Items/Fruits/Collected.png"));

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

Como indicamos que implementamos la interface Element, debemos definir los métodos especificados en dicha interfaz:

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

Si vemos las imágenes que utilizamos, más allá de las imágenes de cada fruta, también utilizaremos una llamada Collected.png; será la que invocaremos cuando el personaje recolecte a la fruta.

Teniendo esto en consideración incorporamos lo siguiente en la clase Fruit:

    private boolean collected = false;
    private boolean visible = true;

    public void setCollected(boolean collected) {
        this.collected = collected;
        this.imageNumber = 0;
    }

    public boolean isCollected() {
        return collected;
    }

    public boolean isVisible(){
        return visible;
    }

Cada Fruit comienza estando visible y teniendo en false collected; aún no fue recolectada; cuando el jugador la recolecta cambia el estado de collected a true; se debe mostrar las imágenes correspondientes al sprite de collected y deja de ser visible en el juego; visible pasa a ser false.

Con esto en mente podemos definir el método updateFruit():

    public void updateFruit() {
        if(collected) {
            if(imageNumber < (imageCollected.getWidth()/width)-1) {
                imageNumber++;
            } else {
                visible = false;
            }
        } else {
            
            if(imageNumber < (imageFruit.getWidth()/width)-1) {
                imageNumber++;
            } else {
                imageNumber = 0;
            }            
        }  
    }

Este método actualiza el parámetro requerido para formar el sprite. También determina si la fruta sigue siendo visible, luego que se haya mostrado todas las imágenes del sprite correspondiente.

También necesitaremos definir el método getImageFruit(), en función de si la fruta fue recolectada o no devuelve la imagen correspondiente:

    public BufferedImage getImageFruit() {
        int x = imageNumber*width;
        if(collected) {
            return imageCollected.getSubimage(x, 0, width,height);
        } else {
            return imageFruit.getSubimage(x, 0, width,height);
        }
    }

¿Como incorporamos Fruit al juego?

En este punto tenemos la clase Fruit casi terminada, pero ya podríamos ir viendo como la integramos en el juego.

Es claro que van a existir múltiples frutas presentes en cada nivel y que estarán dispersas en el mapa; para llevar la gestión utilizaremos un ArrayList como estructura de datos, donde iremos agregando y quitando las frutas según sea necesario.

Definiremos un ArrayList llamado listFruit:

ArrayList<Fruit> listFruit;

public RunnerOne() {
  /** resto del codigo */

  Fruit fruit = new Fruit(Fruit.BANANAS,new Point(150,410));

  listFruit = new ArrayList<Fruit>();
  listFruit.add(fruit);

  /** resto del codigo */
}

El ArrayList que definimos solo aceptara objetos del tipo Fruit, en el constructor instanciamos una fruta del tipo BANANA y le indicamos cual sera su punto inicial en el plano. Luego instanciamos listFruit y agregamos la fruta a la lista.

Aunque es una metodología estática, nos permite probar los cambios que realizamos, más adelante necesitaremos algún procedimiento que cargue los datos del mapa en forma dinámica.

Será necesario modificar el método updateGame() para que actualice los parámetros de las frutas:

public void updateGame() {
  /** resto del codigo **/

  if(!listFruit.isEmpty()) {
    Iterator it = listFruit.iterator();
    while(it.hasNext()) {

      Fruit fruit = (Fruit)it.next();

      fruit.updateFruit();
      if(!fruit.isVisible()) {
        it.remove();
      }
    }
  }  

}

La tarea que se realice es recorrer el listado de frutas, por medio de un Iterator, actualizar la fruta; si la fruta no es visible se la remueve de la lista.

Será necesario también modificar el método paint() para que dibuje las frutas:

public void paint(Graphics g) {
  /** resto del codigo **/
  if(!listFruit.isEmpty()) {
    Iterator it = listFruit.iterator();
    while(it.hasNext()) {
      Fruit fruit = (Fruit)it.next();   
      if(fruit.isVisible())  {
        g.drawImage(fruit.getImageFruit(),fruit.getX(),fruit.getY(), null);
      }
    }
  }

  /** dibujamos el player **/
}

Si no hay mayores inconvenientes cuando ejecutemos nuestro programa debemos tener lo siguiente:

<insertar gif>

Nuestro personaje parece nunca alcanzar la fruta; esto es porque siempre la dibujamos en el mismo punto; debemos cambiar position.x de la fruta siempre que modifiquemos el fondo; para ello debemos hacer algunos cambios en updateFruit():

public void updateFruit(boolean move) {

  /** resto del codigo **/
  
  if(move) {
    position.x--;
    visible = (position.x > 0);
  }

}

Recibirá como parámetro una variable del tipo boolean, que le indica si corresponde que se mueve o no; puede que nuestro personaje este IDLE y no necesitemos desplazar la fruta; si es true; entonces disminuye position.x en 1 y evalúa que sea mayor a cero, en caso de ser falso, tampoco debería ser visible.

En updateGame() debemos incorporar el siguiente código:

public void updateGame() {
  /** resto del codigo **/

  boolean moved = (player.getState() != Player.STATE_IDLE);
  if(!listFruit.isEmpty()) {
    Iterator it = listFruit.iterator();
    while(it.hasNext()) {

      Fruit fruit = (Fruit)it.next();

      fruit.updateFruit(moved);
      if(!fruit.isVisible()) {
        it.remove();
      }
    }
  } 
}

Si compilamos y ejecutamos, se crea el efecto de que la fruta se acerca a nuestro héroe cuando el mismo esta corriendo o saltando; es decir cuando avanza. Al deternerse la fruta queda en el lugar; pero siempre se actualiza su imagen.

Recolectar la fruta

Todavía nos queda realizar algunos por realizar, debemos lograr que cuando el personaje recolecte la fruta esta cambie de estado.

Necesitamos implementar un método que nos indique si Player colisiona con la fruta; es decir, si en algún momento se superponen las imágenes.

Por el momento solo nos vamos a concentrar en que ocurre si el Player avanza sobre la fruta, y no tendremos en cuenta el eje y.

Sabemos que la imagen del jugador esta definida dentro de los puntos (px,py) y (px+w,py+h); siendo w el ancho (width) y h el alto (height). La fruta esta definida a partir de los puntos (fx,fy).

Entonces cuando (px+w) sea mayor a fx; nuestro personaje habrá avanzado sobre la fruta y debemos recolectarla.

Con esta condición en mente, y sabiendo que implementamos la interface Element en Fruit, podemos definir el siguiente método de manera genérica para que nos sirva para otros elementos:

	private boolean isHorizontalColision(Element elA,Element elB,int tolerance) {
		boolean isColision = false;

		if((elA.getX()+elA.getWidth()+tolerance) > elB.getX()) {
			isColision = true;
		}

		return isColision;
	}

isHorizontalColision() es un método que retornara true cuando los dos Element que recibe como parámetro cumplan la condición que establecimos, incorporamos un tercer parámetro que se llama tolerance; es la cantidad de pixeles de superposición que puede existir. Esto es por si la imagen es transparente y necesitamos que avance algunos pixeles para crear el efecto de que la recolecta.

Para que esto funcione debemos implementar la interface Element en Player.

Por ultimo necesitamos realizar algunos cambios en updateGame():

public void updateGame() {
  /** resto del codigo **/

  boolean moved = (player.getState() != Player.STATE_IDLE);
  if(!listFruit.isEmpty()) {
    Iterator it = listFruit.iterator();
    while(it.hasNext()) {

      Fruit fruit = (Fruit)it.next();
      if(isHorizontalColision(player,fruit,-12)) {
         if(!fruit.isCollected()) {
           fruit.setCollected(true);
         }
      }
      fruit.updateFruit(moved);
      if(!fruit.isVisible()) {
        it.remove();
      }
    }
  } 
}

Si existe una colisión entre el player y la fruta, y la fruta aún no fue recolectada, cambiamos el estado de la fruta a recolectada.

Queda para un próximo cambio, incorporar el esquema de puntos.

https://github.com/gsampallo/runnerone

Juego de plataformas en Java (3): Personaje principal

En el articulo anterior agregamos la imagen del fondo de nuestro juego; en esta oportunidad vamos a incorporar al personaje principal al juego.

Para ello utilizaremos la librería de imágenes que descargamos anteriormente

Son sprites, por lo que cada imagen contiene todas las posiciones del personaje para determinada acción; para generar el movimiento solo mostraremos una parte de la imagen principal en determinado momento y lo iremos desplazando a medida que se desarrolle el juego. Algo similar a lo que realizamos con el fondo del juego.

Dentro de la carpeta de image, tenemos una carpeta llamada Characters y dentro de la misma, tenemos una carpeta por cada personaje disponible. La idea es construir una clase general que nos permita manejar a cualquiera de los personajes disponibles.

Comenzaremos creando una clase llamada Player en principio:

package gsampallo;

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

import javax.imageio.ImageIO;

public class Player {
    
    private int type;
    private Point position;

    public Player(int type,Point initialPosition) {
        this.type = type;
        this.position = initialPosition;
    }

}

En función del tipo de personaje sera la ubicación de las imágenes que utilizaremos, debemos crear un método llamada loadImages() que se ocupara de cargar las imágenes necesarias:

    private BufferedImage imageIdle;
    private BufferedImage imageRun;
    private BufferedImage imageJump;
    private BufferedImage imageFall;
    private BufferedImage imageHit;

    private void loadImages() {

        String[] pathImages = {
            "image/characters/Mask Dude/",
            "image/characters/Ninja Frog/",
            "image/characters/Pink Man/",
            "image/characters/Virtual Guy/"
        };

        String pathIdle = pathImages[type]+"Idle.png";
        String pathRun = pathImages[type]+"Run.png";
        String pathJump = pathImages[type]+"Jump (32x32).png";
        String pathFall = pathImages[type]+"Fall (32x32).png";
        String pathHit = pathImages[type]+"Hit (32x32).png";        

        try {

            imageIdle = ImageIO.read(new File(pathIdle));
            imageRun = ImageIO.read(new File(pathRun));
            imageJump = ImageIO.read(new File(pathJump));
            imageFall = ImageIO.read(new File(pathFall));
            imageHit = ImageIO.read(new File(pathHit));
            
        } catch (Exception e) {
            System.err.println("No se pudieron cargar imagenes");
            System.err.println(e.getMessage());
            System.exit(0);
        }

    }

En este punto aclaro que elegir un personaje u otro, solo cambia la parte gráfica; el comportamiento en principio serán los mismos.

Incorporaremos también algunas variables globales para facilitarnos un poco el trabajo:

    public static int MASK_DUDE = 0;
    public static int NINJA_FROG = 1;
    public static int PINK_MAN = 2;
    public static int VIRTUAL_GUY = 3;

    public static int STATE_IDLE = 0;
    public static int STATE_RUN = 1;

    private int width = 32;
    private int height = 32;
    
    private int state;

El personaje puede tener varios estados RUN cuando esta corriendo, IDLE cuando esta detenido; por el momento serán los que utilizaremos.

Finalmente incorporamos los siguientes métodos que nos permite actualizar el personaje y dibujarlo en la pantalla.

    int imageReference = 0;

    public void updatePlayer() {
        if(state == STATE_RUN) {
            if(imageReference < ((imageRun.getWidth()/width)-1)) {
                imageReference++;
            } else {
                imageReference = 0;
            }
        } else if(state == STATE_IDLE) {
            if(imageReference < ((imageRun.getWidth()/width)-1)) {
                imageReference++;
            } else {
                imageReference = 0;
            }
        }
    }


    public BufferedImage getImagePlayer() {
        int x = imageReference*width;
        if(state == STATE_RUN) {   
            return imageRun.getSubimage(x, 0,width,height);
        } else {
            
            //Return Idle Image
            return imageIdle.getSubimage(x, 0,width,height);
        }
    }


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

    public int getY() {
        return position.y;
    }

Los métodos getX() y getY() permite ubicarlo en la pantalla del juego.

Finalmente debemos modificar los métodos updateGame() y paint() en RunnerOne para incorporar el personaje:

    public void updateGame() {

        /*
         * Background
         */
        background.updateBackground();

        /*
         * Player
         */ 
        player.updatePlayer();
    }

	public void paint(Graphics g) {
        g.drawImage(background.getImageBackground(), 0, 0, null);
        
        g.drawImage(player.getImagePlayer(),player.getX(),player.getY(), null);
    }

No olvidemos instanciarlo en el constructor:

Player player;

    public RunnerOne() {
		/* resto del codigo */

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

    }

Cuando lo ejecutamos obtenemos el personaje corriendo.

El gif puede demorar en cargar.



https://github.com/gsampallo/runnerone

https://youtu.be/HuLxfCf34ic