Juego de plataformas en Java (4): Acciones

En el articulo anterior incorporamos a nuestro personaje en el juego de manera muy básica solo actualiza las imagen del sprite para dar la apariencia que esta corriendo.

Incluiremos una nueva clase en el juego que permite realizar acciones antes los eventos producidos con el teclado.

Para ello dentro de la clase RunnerOne incorporaremos una clase interna llamada GameKeys que sera como la siguiente:

    public class GameKeys extends KeyAdapter {

		public void keyPressed(KeyEvent e) {

			if(e.getKeyCode() == KeyEvent.VK_ESCAPE) {
				System.exit(0);
			}
			
		}
    }

Basicamente creamos una clase que extiende de KeyAdapter, para no tener que definir todos los métodos de la interface KeyListener. El metodo keyPressed() se ejecutara cada vez que presionamos una tecla.

En el bloque de código anterior en principio solo atenderemos al evento que propio de la tecla ESCAPE, al presionarla saldremos del juego.

Será necesario también instanciar la clase GameKeys y agregarla como KeyListener a la clase principal, entonces en el constructor agregamos la siguiente linea:

public RunnerOne() {
  /* resto del codigo */
  this.addKeyListener(new GameKeys());
}

Si compilamos y ejecutamos el juego, al presionar la tecla escape saldremos del juego.

Pausar el Juego

Agregaremos algo de código para poder pausar el juego, comenzaremos definiendo una variable global del tipo boolean llamada pause en RunnerOne:

private boolean pause = false;

Modificaremos GameKeys agregamos el siguiente bloque:

			if(e.getKeyCode() == KeyEvent.VK_ESCAPE) {
                System.exit(0);
            } else if(e.getKeyCode() ==  KeyEvent.VK_P) {
                pause = !pause;
			}

Por ultimo en el método updateGame() incorporaremos esta linea al comienzo:

    public void updateGame() {
        if(pause) {
            return;
        }
   /* resto del codigo */
}

Lo que hace es al pulsar la tecla P cambia el valor de pause a true (se pausa el juego) y cuando se invoca a updateGame() simplemente devuelve el método sin ejecutar las instrucciones posteriores.

Correr y detenerse

Hasta ahora nuestro personaje no respondía a ninguna evento del teclado, vamos a cambiar eso; solo se va a desplazar cuando presionemos las teclas W o la fecha derecha, al soltarlo se va a detener.

Para ello agregaremos un par de métodos a la clase Player:

    public int getState() {
        return state;
    }

    public void idle() {
        state = Player.STATE_IDLE;
    }

    public void run() {
        state = Player.STATE_RUN;
    }

Básicamente son para determinar cual es el estado actual de nuestro personaje y para cambiar el estado (run o idle).

Incorporaremos algunos cambios en la clase GameKeys:

    public class GameKeys extends KeyAdapter {

		public void keyPressed(KeyEvent e) {
            
			if(e.getKeyCode() == KeyEvent.VK_ESCAPE) {
                System.exit(0);
            } else if(e.getKeyCode() ==  KeyEvent.VK_P) {
                pause = !pause;

            /*
             * RIGHT
             */
            } else if(e.getKeyCode() ==  KeyEvent.VK_RIGHT) {
                player.run();
            } else if(e.getKeyCode() ==  KeyEvent.VK_D) {
                player.run();
			}
			
        }
        
        public void keyReleased(KeyEvent e) {

            /*
             * RIGHT
             */            
            if(e.getKeyCode() ==  KeyEvent.VK_RIGHT) {
                player.idle();
            } else if(e.getKeyCode() ==  KeyEvent.VK_D) {
                player.idle();
            }
        }
    }

Como se puede ver, definimos las acciones que se van a tomar cuando se libere la tecla presionada, en este caso al soltar las teclas VK_RIGHT (flecha derecha) y D, se cambia el estado del player a IDLE.

Modificaremos el metodo updateGame() para que actualice el fondo solo si el estado del player es IDLE:

        if(player.getState() != Player.STATE_IDLE) {
            background.updateBackground();
        }

Cuando lo ejecutamos el personaje solo se va a mover mientras se mantenga presionada las teclas W o RIGHT.

El gif puede demorar en cargar.

Jump!

Una acción fundamental en casi todos los juego de plataformas es que nuestro personaje pueda saltar.

Primero debemos definir dentro de GameKeys.keyPressed() la acción:

            /*
             * JUMP
             */
            } else if(e.getKeyCode() ==  KeyEvent.VK_UP) {
                player.jump();

Será necesario implementar algunos cambios en Player, comenzando por definir el método jump():

Nuestro personaje al saltar no solo debe cambiar la imagen que se utiliza para graficarlo, sino que también debemos ir modificando el valor de position.y para que cambie su ubicación.

Definiremos una variable llamada jumpDistance, que sera la responsable de determinar cuan «alto» saltara nuestro personaje. También definiremos una variable llamada baseLine, que mantendrá el valor original de position.y, sera el limite inferior o el «piso».


    public static int STATE_IDLE = 2;
    public static int STATE_JUMP = 3;
    public static int STATE_FALL = 4;

    private int jumpDistance = height;
    private int baseLine = 0;
    private int previousState = 0;
    

Antes de continuar debemos hacer un breve análisis de como sera el comportamiento de nuestro personaje respecto a sus estados, en que momento podrá cambiar entre ellos:

Esto nos sirve de guía para definir algunos métodos nuevos en Player y modificar otros:

    public void idle() {
        if(state == Player.STATE_RUN) {
            state = Player.STATE_IDLE;
        }
    }

    public void run() {
        if(state == Player.STATE_IDLE) {
            state = Player.STATE_RUN;
        }
    }


	public void jump() {
        if((state == Player.STATE_RUN) || (state == Player.STATE_IDLE)) {
            previousState = state;
            state = Player.STATE_JUMP;
            baseLine = position.y;
        }
    }
    
    public void fall() {
        state = Player.STATE_FALL;
    }

El método run() solo podrá ser invocado cuando el estado de nuestro personaje este IDLE, ocurre cuando se genera el evento Release en GameKeys.

En jump() si es valida la condición del estado, almacenamos el estado previo (si estaba corriendo que siga corriendo), luego cambiamos el estado de Player y almacenamos la posición en y actual; serla el punto al que volveremos si no hay ningún obstáculo delante; esto lo veremos en un articulo más adelante.

Modificamos el método updatePlayer() paa que quede de la siguiente manera:

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

        } else if(state == STATE_JUMP) {
            if(position.y > (baseLine-jumpDistance)) {
                position.y = position.y - 2;
            } else {
                fall();
            }

        } else if(state == STATE_FALL) {
            if(position.y < baseLine) {
                position.y = position.y + 2;
            } else {
                state = previousState;
            }
            

        } else if(state == STATE_IDLE) {
            if(imageReference < ((imageRun.getWidth()/width)-2)) {
                imageReference++;
            } else {
                imageReference = 0;
            }
        }
    }

Cuando el estado es JUMP disminuiremos position.y tanto como sea el valor de jumpDistance; una vez que sea igual o menor, invocamos al método fall(); es decir, cuando ya alcanzo el punto máximo del salto, debe comenzar a caer.

En el estado FALL hacemos lo opuesto a JUMP, aumentamos el valor de position.y hasta que sea igual a baseLine (el valor y original antes del salto) y volvemos al estado previo.

Por ultimo nos queda modificar el método: getImagePlayer() para que devuelva las imágenes de JUMP y FALL:

    public BufferedImage getImagePlayer() {
        
        if(state == STATE_RUN) {   
            int x = imageReference*width;
            return imageRun.getSubimage(x, 0,width,height);
        } else if(state == STATE_JUMP) {   
            return imageJump;
        } else if(state == STATE_FALL) {   
            return imageFall;
        } else {
            //IDLE
            int x = imageReference*width;
            return imageIdle.getSubimage(x, 0,width,height);
        }
    }

Si compilamos y ejecutamos, cada vez que presionemos la tecla W o UP tendremos:

El gif puede demorar en cargar.

Si ven que parece que faltan frames o se pierde la imagen del salto, es que de verdad ocurre; una deuda técnica que dejamos por ahora.

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