Capítulo 2 Desarrollo de un videojuego 2D en Java El siguiente capítulo forma parte del material docente de la asignatura de Inteligencia Artificial para las carreras de Ingeniería Civil en Informática y de Ejecución en Computación e Informática de la Universidad del BíoBío. Su objetivo es explicar, paso a paso, el desarrollo de un videojuego 2D mediante el lenguaje de programación Java (sin emplear ninguna librería especializada para el desarrollo de videojuegos, ni motor gráfico, ni de inteligencia artificial). Posteriormente las técnicas de búsqueda IA vistas en las clases teóricas se incorporaran con el objetivo de dotar a los personajes de un comportamiento autónomo e inteligente. Entendiendo inteligencia cómo la capacidad de adaptación de una entidad en un mundo cerrado en función de cierta información previa y cambiante. El código fuente que se presenta es de programación libre y personal, no sigue los estándares de programación orientada objetos, todos los atributos son públicos para conseguir un prototipado más directo. El objetivo es que éste sea lo más sencillo posible de entender para un programador no experto. Un programador experto podrá mejorarlo con facilidad y adaptarlo a los patrones de la POO y a los estándares requeridos cuando se trabaja en proyectos de varios programadores. No se sigue ningún patrón de diseño de desarrollo software, lo que no significa que no se utilice programación de calidad. 2.1. Construcción de un escenario 2D basado en celdas. Cuando observamos un escenario virtual 2D -dos dimensiones- (ver Figura 2.1) podemos identificar muchos elementos visuales: obstáculos, personajes secundarios, premios, vidas, tiempo, puntos acumulados, etc. Un escenario virtual tiene diferentes capas en las cuales aparecen estos elementos y cada uno de éstos se sitúan en una posición determinada. Dicha posición puede ser fija o variar, es decir, habrá objetos sin movimiento (barreras, premios, caminos) y con movimiento (personaje, adversarios, coches, etc). 7 Figura 2.1: Ejemplo de un Escenario o Entorno 2D de un vídeo juego comercial Una forma de diseñar este escenario consiste en dividirlo en celdas. Una celda (ver Figura 2.2) es un elemento atómico, representado habitualmente por un cuadrado o un rectángulo, que tendrá una determinada posición y unas dimensiones concretas. El objetivo principal de nuestra inteligencia artificial será que algunas de esas celdas, las que representan un personaje o un adversario, se muevan de forma automática con algún tipo de objetivo. Si nos pidieran dibujar el escenario anterior, la primera opción sería usar lápices de colores y papel, como lo hacíamos en el colegio cuando eramos pequeños. En el lenguaje de programación Java esto equivale al objeto Canvas (papel o lienzo) y al objeto Graphics (lápiz y lapices de colores). Por ejemplo, si quisiéramos pintar un circulo, tomaríamos papel, lápiz y pintaríamos un circulo en cualquier posición del papel. Para hacer esto en Java, debemos crear un objeto Canvas (zona de dibujo), y usar el objeto Graphics (nuestro lápiz) haciendo uso de los métodos de los que esta clase dispone para dibujar cualquier figura geométrica en 2D (para más detalle ver la API de Java). La única diferencia entre el método manual y el computacional es que la pantalla está dividida en píxeles, por tanto tenemos que indicar al sistema dónde queremos dibujar el círculo y cuáles serán sus dimensiones. El objetivo de esta sesión es mostrar, de forma práctica, los recursos disponibles en Java para construir escenarios en dos dimensiones. Inicialmente partiremos de un plano vacío constituido por un conjunto de celdas. Nuestro objetivo es conseguir una ventana dividida en celdas o cuadrantes. En cada cuadrante se podrá, posteriormente, colocar cualquier elemento: un personaje, un obstáculo, una moneda, una trampa, etc, representado por una imagen representativa de tal elemento (el tamaño de las celdas y de las imágenes será el mismo con el objetivo de facilitar la programación de los algoritmos de búsqueda). El primer paso a realizar consiste en ser capaces de pintar Celdas. Serán nuestras unidades atómicas, indivisibles. Para pintar una celda haremos uso de la clase Celda que implementará la funcionalidad para dibujar rectángulos de unas determinadas dimensiones almacenadas en la interfaz Constantes. public interface Constantes { public public public public public final final final final final int int int int int SIZE_CELL=16; NUMBER_CELL_WIDTH=150; NUMBER_CELL_HEIGHT=50; SIZE_WIDTH=SIZE_CELL*NUMBER_CELL_WIDTH; SIZE_HEIGHT=SIZE_CELL*NUMBER_CELL_HEIGHT; } La interfaz Constantes la utilizamos como archivos e configuración, es decir, para definir e inicializar variables que se utilizaran en más de una clase y que requerimos sean visibles para ellas al inicio de la ejecución. Por ahora: i) anchura y altura de las celdas (SIZE_CELL); ii) número de celdas a lo largo y a lo ancho (NUMBER_CELL_WIDTH,NUMBER_CELL_HEIGHT); iii) anchura y altura del mundo virtual, esto, tamaño de una celda por el número de celdas a lo ancho y a lo alto, respectivamente. Es conveniente, como primera decisión diseño, decidir que el tamaño de las celdas sea igual al tamaño de las imágenes que usaremos para crear nuestro mundo virtual (esto lo veremos en capítulos posteriores). import java.awt.Graphics; import javax.swing.JComponent; public class Celda extends JComponent implements Constantes { //atributos public int coordenadaX; public int coordenadaY; //constructor, inicializa los atributos public Celda(int x,int y) { this.coordenadaX=x; this.coordenadaY=y; } //metodo de JComponent para pintar en un contexto grafico @Override public void paintComponent(Graphics g) { g.drawRect(coordenadaX,coordenadaY,SIZE_CELL,SIZE_CELL); } } La función g.drawRect(X,Y,Ancho,Largo); dibuja un rectángulo en la posición x e y, de ancho Ancho y de largo Largo. La clase Laberinto se encarga de crear el esqueleto del futuro laberinto. Celdas que representan las posiciones donde los personajes y objetos podrán estar en un momento dado. Por tanto hará uso de la clase Celda creado un matriz de N por M . Igual que la clase Celda, ésta cuenta con un método paintComponent encargado de pintar en el Lienzo el esqueleto basado en Celdas. import java.awt.Graphics; import javax.swing.JComponent; public class Laberinto extends JComponent implements Constantes { public Celda[][] celdas; public Laberinto() { celdas=new Celda[NUMBER_CELL_WIDTH][NUMBER_CELL_HEIGHT]; //inicializar el array de celdas for(int i=0; i < NUMBER_CELL_WIDTH; i++) for ( int j=0 ; j < NUMBER_CELL_HEIGHT ; j++) celdas[i][j]=new Celda(i+(i*SIZE_CELL),j+(j*SIZE_CELL)); } @Override public void paintComponent(Graphics g) { for(int i=0; i < NUMBER_CELL_WIDTH ; i++) for ( int j=0 ; j < NUMBER_CELL_HEIGHT; j++) celdas[i][j].paintComponent(g); } } La clase Lienzo hará la funcional de papel, es decir, un objeto donde podremos dibujar objetos 2D. Para ello es necesario heredar de la clase Canvas que cuenta con la funcionalidad necesaria para ello. Una vez elegido el color de fondo, indicaremos su tamaño. Cuando un objeto Canvas se instancia y se añade a un contenedor (ver página siguiente) se llamará al método paint encargado de pintar los objetos indicados en él. import java.awt.Canvas; import java.awt.Color; import java.awt.Graphics; public class Lienzo extends Canvas { public Laberinto laberinto; public Lienzo(){ laberinto=new Laberinto(); this.setBackground(Color.orange); } //metodo llamada la primera que se pinta @Override public void paint(Graphics g) { laberinto.paintComponent(g); } } Por último, tendríamos las dos últimas clases: la clase VentanaPrincipal para visualizar el lienzo sobre un JFrame y la clase Main que contiene el método main(). La clase VentanaPrincipal hereda de JFrame, adquiriendo de esta forma todas las funcionales para dibujar una ventana (implementadas en JFrame y sus clases padre). Posteriormente, ajustaremos el tamaño de la ventana al tamaño de la pantalla del dispositivo en el cual estemos ejecutando la aplicación. Para ello, hacemos uso de la función getScreenSize() de super.getToolkit(), dicha función devuelve la dimensión de la pantalla que asignaremos a un objeto del tipo Dimension llamado sizeScreen import java.awt.Dimension; import javax.swing.JFrame; public class VentanaPrincipal extends JFrame { //nuestra clase se compone de un lienzo de dibujo (herada de canvas) public Lienzo lienzo; public Dimension sizeScreen; //constructor public VentanaPrincipal() { lienzo=new Lienzo(); sizeScreen=super.getToolkit().getScreenSize(); this.getContentPane().add(lienzo); this.setSize(sizeScreen.width,sizeScreen.height); } } import javax.swing.JFrame; public class Main { public static void main (String[]args) { VentanaPrincipal vp=new VentanaPrincipal(); vp.setVisible(true); vp.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); } } Con este pequeño conjunto de clases tendríamos implementado el escenario 2D basado en celdas que pretendíamos al inicio. Figura 2.2: Plano vacío del escenario donde iremos colocando las entidades de nuestro mundo virtual Capítulo 3 Dando vida al escenario El objetivo de este capitulo es mostrar al alumno cómo hacer que el plano 2D vacío comience a ser interactivo, es decir, reaccione a los eventos de entrada (habitualmente de ratón o teclado). La interacción de las aplicaciones Java 2D se consigue a través de la captura de eventos. La terminología en Java es “escuchadores” de eventos (listeners). Eventos de entrada/salida o eventos que nosotros creamos para que un determinado elemento haga una determinada acción en un instante concreto. La clase Constantes permanece intacta. En la clase Celda debemos incorporar un nuevo atributo que denominaremos celdaSeleccionada , inicializada a false. Esto quiere decir que inicialmente ninguna celda está seleccionada. Para ello, extendemos el método paintComponent con el objetivo de distinguir entre estos dos nuevos estados: i) celda seleccionada; ii) celda no seleccionada. @Override public void paintComponent(Graphics g) { g.drawRect(coordenadaX,coordenadaY,SIZE_CELL,SIZE_CELL); if ( celdaSeleccionada ) g.fillRect(coordenadaX,coordenadaY,SIZE_CELL,SIZE_CELL); } Una vez que hemos extendido este método debemos incorporar el método comprobarSiCeldaSeleccionada(int clickX,int clickY) con el objetivo de identificar la celda que se ha seleccionado. Para ello construimos un área igual a las coordenadas obtenidas en el evento del ratón. A estas coordenadas le sumamos la anchura y la altura de cada celda. Esto lo hacemos empleando la clase Rectangle que nos permite saber si un punto está incluido en un rectángulo. Si el punto está incluido en el rectángulo entonces sabremos que la celda actual se ha seleccionado y debemos modificar el atributo celdaSeleccionada con el valor true. public void comprobarSiCeldaSeleccionada(int clickX,int clickY) { 13 Rectangle rectanguloCelda=new Rectangle(coordenadaX,coordenadaY, SIZE_CELL,SIZE_CELL); if ( rectanguloCelda.contains(new Point(clickX,clickY)) ) celdaSeleccionada = !celdaSeleccionada; } La clase Celda por tanto quedaría como se muestra a continuación: import import import import java.awt.Graphics; java.awt.Point; java.awt.Rectangle; javax.swing.JComponent; public class Celda extends JComponent implements Constantes { //posicion x e y de la Celda, no cambia durante la ejecucion public int coordenadaX; public int coordenadaY; //variable que indica que una celda fue seleccionada public boolean celdaSeleccionada;//estado de la celda //constructor public Celda(int x,int y) { this.coordenadaX=x; this.coordenadaY=y; this.celdaSeleccionada=false; } //metodo para dibujar celda, hace uso de drawRect //metodo para dibujar celda, hace uso de drawRect @Override public void paintComponent(Graphics g) { g.drawRect(coordenadaX,coordenadaY,SIZE_CELL,SIZE_CELL); if ( celdaSeleccionada ) { g.fillRect(coordenadaX,coordenadaY,SIZE_CELL,SIZE_CELL); } } //si el click esta sobre la celda public void comprobarSiCeldaSeleccionada(int clickX,int clickY) { Rectangle rectanguloCelda=n ew Rectangle(coordenadaX,coordenadaY,SIZE_CELL,SIZE_CELL); if ( rectanguloCelda.contains(new Point(clickX,clickY)) ) { celdaSeleccionada = !celdaSeleccionada; } } } La clase Laberinto quedaría con el mismo código. La clase Lienzo se extenderá para dar soporte a los eventos. Los dos eventos que vamos a considerar son: 1. Activar celda mediante eventos de ratón. 2. Desplazar celda (arriba, abajo, derecha, izquierda) mediante eventos de teclado. 3.1. Eventos de ratón Note que el evento de ratón al final no se utilizará a no ser que lo mantengamos para colocar determinados objetos, es decir, que el usuario pueda diseñar sus propios escenarios. EL primer paso consiste en añadir un escuchador a la clase Lienzo mediante el método addMouseListener cuyo argumento es un objeto MouseAdapter que posee un método que implementa el método mouseClicked encargado de implementar la funcionalidad de capturar un evento de ratón. Cuando sobrescribimos ese método lo que hacemos es implementar la nueva funcionalidad al hacer click, en este caso deberemos identificar la celda que se ha pulsado comprobando que la coordenada X e Y del ratón pertenece alguna de las celdas previamente creadas. Esto lo realizaremos con un método privado denominado activarCelda(MouseEvent evt). La clase Lienzo quedaría de la siguiente forma. import import import import import java.awt.Canvas; java.awt.Color; java.awt.Graphics; java.awt.event.MouseAdapter; java.awt.event.MouseEvent; public class Lienzo extends Canvas implements Constantes{ //para pintar el lienzo public Laberinto laberinto; public Lienzo(){ laberinto=new Laberinto(); //color de fondo this.setBackground(Color.orange); //añadimos el escuchador addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent evt) { activarCelda(evt); repaint(); } }); } //metodo llamada la primera vez que se pinta @Override public void paint(Graphics g) { laberinto.paintComponent(g); } private void activarCelda(MouseEvent evt) { for(int i=0; i < NUMBER_CELL_WIDTH ; i++) for ( int j=0 ; j < NUMBER_CELL_HEIGHT; j++) laberinto.celdas[i][j]. comprobarSiCeldaSeleccionada(evt.getX(),evt.getY()); } } Las clases VentanaPrincipal y Main quedarían igual. 3.2. Eventos de teclado En esta parte del documento vamos a explicar como podemos capturar los eventos de teclado para mover, por ejemplo, una celda. Solamente las clases Laberinto.java y Lienzo.java van a sufrir cambios. El primer paso, similar a lo que hacíamos en el caso de los eventos de ratón, debemos añadir un escuchador de eventos de teclado. Nos situamos en la clase Lienzo.java y a continuación del código para capturar eventos de ratón colocamos el siguiente código: //escuchador eventos de teclado addKeyListener(new java.awt.event.KeyAdapter() { @Override public void keyPressed(KeyEvent e) { laberinto.moverCelda(e); repaint(); } }); Este código nos proporciona la funcionalidad para añadir un nuevo escuchador de eventos de teclado haciendo uso del método addKeyListener que permite crear un objeto del tipo KeyAdapter. Este objeto posee un método keyPressed que proporciona la funcionalidad para identificar la tecla que se pulsó. Se puede sobre escribir e incorporar nuestra propia funcionalidad. Lo que haremos será llamar a un método moverCelda donde incorporaremos las acciones en función de la tecla pulsada. Después de haber pulsado una tecla, posiblemente, el escenario habrá sufrido alguna modificación, por ello la llamada al método repaint() que volverá a dibujar el escenario con los cambios que se hayan podido producir tras los eventos. El segundo paso consiste en elegir la celda qué queremos mover. Por simplicidad aquí se ha elegido la primera celda, es decir, celda cuya coordenada X es igual a cero, y cuya coordenada Y es igual a cero. Creamos por tanto una Celda llamada public Celda celdaMovimiento; que inicializaremos en el constructor con las coordenadas cero, cero: celdaMovimiento=new Celda(0,0); , a continuación debemos emplear los índices de esta celda para indicar que ha sido seleccionada para que aparezca en negro y podamos moverla: celdas[celdaMovimiento.x][celdaMovimiento.y].celdaSeleccionada=true; Figura 3.1: Celda seleccionada para moverse por el escenario: arriba, abajo, izquierda o derecha Aquí implementamos el método chequear tecla que recibe como argumento un objeto del tipo KeyEvent. Dicho objeto posee un método denominado getKeyCode(). Para saber cuáles son los códigos que corresponden a las flechas de las teclas arriba, abajo, izquierda, derecha; podemos acceder a la clase KeyEvent desde NetBeans situando el ratón sobre el nombre, pulsando control y haciendo click sobre él. Esta clase posee unas constantes junto a sus códigos: /** * Constant for the non-numpad <b>left</b> arrow key. * @see #VK_KP_LEFT */ public static final int VK_LEFT = 0x25; /** * Constant for the non-numpad <b>up</b> arrow key. * @see #VK_KP_UP */ public static final int VK_UP = 0x26; /** * Constant for the non-numpad <b>right</b> arrow key. * @see #VK_KP_RIGHT */ public static final int VK_RIGHT = 0x27; /** * Constant for the non-numpad <b>down</b> arrow key. * @see #VK_KP_DOWN */ public static final int VK_DOWN = 0x28; Por tanto, el método moverCelda queda como sigue: public void moverCelda( KeyEvent evento ) { switch( evento.getKeyCode() ) { case KeyEvent.VK_UP: System.out.println("Mover arriba"); moverCeldaArriba(); break; case KeyEvent.VK_DOWN: System.out.println("Mover abajo"); moverCeldaAbajo(); break; case KeyEvent.VK_LEFT: System.out.println("Mover izquierda"); moverCeldaIzquierda(); break; case KeyEvent.VK_RIGHT: System.out.println("Mover derecha"); moverCeldaDerecha(); break; } } A continuación implementamos el método moverCeldaArriba. Como tarea se deja la implementación de los otros tres métodos restantes al lector. El resultado debería ser similar a la Figura 3.1 donde la celda que aparece en la posición cero, cero; se puede mover por el escenario pulsando las flechas: arriba, abajo, izquierda, derecha. private void moverCeldaArriba(){ if (celdaMovimiento.y > 0 ) { celdas[celdaMovimiento.x][celdaMovimiento.y]. celdaSeleccionada=false; celdaMovimiento.y=celdaMovimiento.y-1; celdas[celdaMovimiento.x][celdaMovimiento.y]. celdaSeleccionada=true; } }