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 (2): Background

En el articulo anterior creamos el proyecto y el repositorio que vamos a utilizar para armar el juego.

Hoy nos vamos a enfocar en desarrollar la «cascara» del juego, comenzaremos por los gráficos. La idea es que el personaje avance en la pantalla de izquierda a derecha, se encuentre sobre la parte inferior de la pantalla/ventana y en caso que salte se lo dibuje más «arriba».

La idea es emplear una imagen de fondo por nivel, que se vaya repitiendo, de manera que brinde continuidad.

Como no soy diseñador gráfico busque en internet de dónde podía descargar imágenes que pueda utilizar en el juego.

Encontré una web (yo no la conocía) llamada https://www.gamedevmarket.net/ desde donde podemos bajar imágenes de todo tipo para nuestro juego.

Las que utilice son gratuitas Pixel Adventure y Pixel Adventure II para los personajes, items y enemigos; y luego busque el fondo.

La imágen que voy a utilizar para el fondo del juego en el primer mapa es la siguiente:

Lo que hice fue tomar imágen anterior, la original, duplicar el largo de la mesa de trabajo (en el gimp) y luego copiar la imagen y espejarla; de esa forma queda más larga y es más sencillo dar el efecto de continuidad.

Queda de la siguiente manera:

Con esta imágen crearemos el primer mapa de nuestro juego.

Crearemos una carpeta llamada image dentro de la carpeta del proyecto, donde ubicaremos la imagen anterior.

Modificaremos la clase RunnerOne, para que sea un JFrame, que al ejecutarse este en el centro de la pantalla y tenga una resolución de 640×480.

Agregamos dos variables widht y height, que contendrán el largo y alto en pixeles de la ventana, y luego nos servirán para determinar el comportamiento del juego.

package gsampallo;

import javax.swing.JFrame;
import java.awt.*;

public class RunnerOne extends JFrame {

    public static int FRAME_WIDTH = 640;
    public static int FRAME_HEIGHT = 480;

    public RunnerOne() {
		setTitle("RunnerOne");

		this.setSize(FRAME_WIDTH,FRAME_HEIGHT);
		this.setResizable(false);

		this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
		Dimension dim = Toolkit.getDefaultToolkit().getScreenSize();
		this.setLocation(dim.width/2-this.getSize().width/2, dim.height/2-this.getSize().height/2);

		
		setVisible(true);
    }

    public static void main(String[] args) throws Exception {
        RunnerOne runnerOne = new RunnerOne();
    }
}

También agregamos el constructor, donde indicamos el nombre, definimos el tamaño, establecemos que la ventana no puede cambiar de tamaño y la ubicación, por ultimo la mostramos en pantalla.

En main() instanciamos la clase y ejecutamos RunnerOne. Si todo es correcto, al presionar F5 tenemos una ventana vacía en el centro de la pantalla.

Podemos agregar contenido a la ventana incorporando JPanels, controles como botones o cajas de textos, pero no son adecuados para el juego, en cambio dibujaremos el contenido; comenzando con la imagen del fondo del juego.

Para ello sobrescribiremos el método paint(); este método es propio de la clase Container y se invoca cada vez que se actualiza el componente gráfico.

Construiremos una clase que se ocupe de cargar la imagen de fondo llamada Background, creamos un nuevo archivo con este nombre, que contendrá:

package gsampallo;

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

import javax.imageio.ImageIO;

public class Background {

    public Background(String imageFile) {

        loadImage(imageFile);
    }

	private BufferedImage background;
	
	private void loadImage(String imageFile) {
        try {
			background = ImageIO.read(new File(imageFile));
        } catch (Exception e) {
            System.err.println("No se pudieron cargar imagenes");
            System.err.println(e.getMessage());
        }
	}


    public BufferedImage getImageBackground() {
        return background;
    }
}

En constructor recibe dos parámetros enteros son el largo y alto de la imagen, serán utilizados luego para «recortar» la imagen original; también recibe un String con el nombre del archivo de la imagen, de esa forma podemos utilizarlo si queremos armar un segundo nivel.

loadImage() es un método privado que se ocupa se cargar la imagen en memoria.

Luego tenemos un segundo método publico, llamado getImageBackground(), este método se va a ocupar de devolver la imagen que cargamos en memoria, por ahora devolvemos la imagen tal cual la cargamos.

Será necesario también agregar las librerías requeridas por VSCode; simplemente hagan clic en el icono de la lampara y luego en import.

En RunnerOne, debemos sobrescribimos el método paint() de la siguiente manera:


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

Previamente definimos background como global en RunnerOne y lo instanciamos en el constructor:

background = new Background("image/bgd1.png");

En este punto si ejecutamos el programa, obtendremos la ventana con la una parte de imagen de fondo.

Como dije al comienzo, en este juego de plataforma me interesa que el fondo se vaya desplazando solo, más allá de lo que realice el jugador.

Será necesario realizar dos acciones:

  1. Agregar un Timer, para que cada cierto intervalo de tiempo actualice los parámetros del juego y vuelva a dibujar la pantalla.
  2. Implementar la interface ActionListener en RunnerOne para que responda al Timer.

Comenzaremos con el punto 2, implementar ActionListener, es sencillo solo tenemos que modificar la definición de la clase y escribir el método:

public class RunnerOne extends JFrame implements ActionListener {
    /* Va el resto del programa .... */
    public void actionPerformed(ActionEvent e) {
        repaint();
    }
}

repaint() le indica a Java que vuelva a pintar el componente.

Ahora debemos definir cual será la frecuencia de actualización del juego, lo hacemos por medio de una variable global llamada DELAY, e instanciamos el Timer en el constructor:

private final int DELAY = 40;
private Timer timer;

public RunnerOne() {
  /** .... resto del codigo del constructor */
  //Timer
  timer = new Timer(DELAY, this);
  timer.start();

  setVisible(true);
}

Cuando importen Timer, utilicen javax.swing.Timer.

Si en este punto compilamos y ejecutamos nuestro programa, sigue sin ocurrir ningún cambio; será necesario trabajar sobre la clase Background para que modifique la imagen con cada llamada.

Existe un método de la clase Graphics, que nos permite dibujar solo parte de la imagen original, lo cual es perfecto para este caso. Modificamos el método getImageBackground(), emplearemos un contador para llevar registro de la posición del «recorte» dentro de la imagen real.

Teniendo en cuenta la imagen anterior, buscamos solo tomar el rectángulo contenido entre los puntos (x,y) y (x+width,y+height); de manera de tener una imagen de 640×480 de resolución. Al incrementar el valor de x, la «ventana» por así decirlo se ira desplazando y creando el efecto de movimiento.

Emplearemos Point como variable para llevar el registro de la posición, Point es una clase que tiene dos variables x,y; especial para ubicar un punto en el plano.

Instanciaremos la variable de tipo Point dentro del constructor de la siguiente forma:

position = new Point(0,(background.getHeight()-RunnerOne.FRAME_HEIGHT));

Modificaremos el método getImageBackground() de la siguiente forma:

    public BufferedImage getImageBackground() {

        if(position.x < background.getWidth()) {
            position.x++;
        } else {
            position.x = 0;
        }

        return background.getSubimage(position.x,position.y,RunnerOne.FRAME_WIDTH,RunnerOne.FRAME_HEIGHT);
    }

Cuando lo ejecutemos tenemos el siguiente resultado:

Durante las pruebas, podemos cambiar el valor de la variable DELAY para completar los ciclos en menos tiempos; asignemos un valor de 10 ms.

Si dejan que el juego se ejecute por un tiempo, van a encontrarse que cuando se acerca a finalizar la imagen presenta el siguiente error:

Exception in thread «AWT-EventQueue-1» java.awt.image.RasterFormatException: (x + width) is outside of Raster

Esto es porque trata de recortar una nueva imagen por fuera de la imagen real del fondo; el diagrama siguiente lo explica mejor:

La sección indicada como null no es posible obtener y se presenta el error comentado anteriormente. Por ello será necesario, modificar la condicion del IF para que en lugar de evaluar position.x evalue position.x+RunnerOne.FRAME_WIDTH:

    public BufferedImage getImageBackground() {
        
        if((position.x+RunnerOne.FRAME_WIDTH) < background.getWidth()) {
            position.x++;
        } else {
            position.x = 0;
        }

        return background.getSubimage(position.x,position.y,RunnerOne.FRAME_WIDTH,RunnerOne.FRAME_HEIGHT);

    }

Si volvemos a compilar y ejecutar el programa, vemos que se desplaza sin problemas; pero pega un salto muy evidente al finalizar la imagen; deberíamos de alguna manera «empalmar» la imagen que finaliza con el comienzo de la misma.

Entonces basicamente cuando no se cumpla la condición de que ((position.x+RunnerOne.FRAME_WIDTH) < background.getWidth()) debemos tener dos imágenes donde una sera parte del final de la imagen de fondo original y la otra sera parte del comienzo.

Tenemos el valor de position.x; debemos obtener el valor de delta; si partimos de la base que position.x+RunnerOne.FRAME_WIDTH) < background.getWidth() es falsa, entonces nos queda que delta = background.getWidth() – position.x. A este valor lo llamaremos xMax1.

Nos queda encontrar el segundo valor, para la segunda imagen que va a completar el cuadro:

xMax2 = (position.x + RunnerOne.FRAME_WIDTH) – background.getWidth();

Modificamos la clase background para que quede de la siguiente manera:

    private boolean isTwoImage = false;
    public void updateBackground() {
        if((position.x+RunnerOne.FRAME_WIDTH+1) < background.getWidth()) {
            position.x++;
            isTwoImage = false;
        } else {

            if(position.x < background.getWidth()) {
                position.x++;
                isTwoImage = true;
            } else if(position.x >= background.getWidth()) {
                position.x = 0;
                isTwoImage = false;
            }

        }        
    }

    private BufferedImage joinImages(BufferedImage img1,BufferedImage img2,int xMin) {
        
        backgroundTotal = new BufferedImage(RunnerOne.FRAME_WIDTH,RunnerOne.FRAME_HEIGHT,background.getType());

        Graphics2D g = backgroundTotal.createGraphics();
        
        g.drawImage(img1,0,0,null);
        g.drawImage(img2,xMin,0,null);

        return backgroundTotal;
    }

    public BufferedImage getImageBackground() {

        if(!isTwoImage) {
            return background.getSubimage(position.x,position.y,RunnerOne.FRAME_WIDTH,RunnerOne.FRAME_HEIGHT);
            
        } else {
            int xMax1 = background.getWidth() - position.x;
            int xMax2 = (position.x + RunnerOne.FRAME_WIDTH) - background.getWidth();
            
            background2 = background.getSubimage(0,position.y,xMax2,RunnerOne.FRAME_HEIGHT);
            
            if(xMax1 > 0) {
                background1 = background.getSubimage(position.x,position.y,xMax1,RunnerOne.FRAME_HEIGHT);
                return joinImages(background1,background2,xMax1);
            } else {
                return background2;
            }
        }

    }

En el método updateGame() de RunnerOne, realizamos una llamada a background.updateBackground() para que actualice los parámetros del fondo.

Al ejecutarlo deberíamos obtener el efecto de que el fondo se mantiene moviéndose de manera continua.

https://youtu.be/kP3E5sGkuYE

Nota: no es una solución perfecta, puesto que aún arroja un error cuando cambia el valor de x, pero no afecta mayormente al resultado; queda pulir un poco más; pero a los efectos de lo que estoy buscando es suficiente.

https://github.com/gsampallo/RunnerOne