Departamento de Sistemas de Información Desarrollo de Videojuegos 2D en Java con técnicas de Inteligencia Artificial Libro de texto creado para la asignatura Escrito por: Dr. Clemente Rubio Manzano Prologo por Michio Kaku. Algunas personas piensan que la inteligencia es el gran logro de la evolución. Pues bien, si esto es así, entonces deberían haber existido muchas más especies inteligentes sobre el planeta Tierra. Pero, por lo que sabemos, somos los únicos. Los dinosaurios, que estuvieron en el planeta durante más de doscientos millones de años, no llegaron a ser inteligentes. Los humanos modernos, han estado en la Tierra aproximadamente ciento de miles de años, una pequeña fracción de la edad de la Tierra, que se estima en 4.5 billones de años. Por tanto podríamos sacar una primero conclusión, la inteligencia no es realmente necesaria para la vida. La madre naturaleza se las ha ingeniado bastante bien sin criaturas inteligentes. Pero, ¿Cómo nos hicimos inteligentes? ¿Qué nos diferencia de los animales? Hay tres ingredientes básicos. Uno es el pulgar oponible que nos permite manipular el entorno. Así que, este es uno de los ingredientes secretos, ser capaces de cambiar el mundo que nos rodea. El segundo ingrediente es la visión. Pero no cualquier visión, una visión de depredador. Tenemos ojos en la frontal de nuestra cabeza, no a los lados y ¿por qué?, porque los animales con ojos en la parte frontal son depredadores (leones, tigres, zorros). Los animales con ojos a los lados son presas y no son inteligentes, como por ejemplo un conejo. Un depredador tiene que aprender a cazar y acechar, aprender a predecir los movimientos de la presa, si no, no podrá comer. Si tú eres un conejo lo único que debes hacer es correr. El tercer ingrediente es el lenguaje porque debes poder comunicarte para que tu conocimiento adquirido durante tu vida pase de generación en generación y por lo que sabemos los animales aunque se comunican no permiten la transmisión de conocimiento, salvo algunas señales primitivas. Los animales no tienen cultura, en el sentido más amplio de la palabra, nosotros sí y somos capaces de transmitirla de forma eficaz. Así se supone que el cerebro evolucionó. Tenemos pulgar oponible, tenemos un lenguaje de entre cinco a diez mil palabras y tenemos la visión de un depredador. Por tanto, quizás la razón por la que ninguna criatura consiguió ser inteligente es que no cuenta con estos tres ingredientes. Entonces, la siguiente pregunta si podemos crear seres inteligentes. ¿Podemos hacer que un chimpancé sea inteligente como ocurrió en el planeta de los simios?. Pues lo creas o no, la respuesta podría ser que sí. Somos iguales a ellos en un 98.5 por ciento. Por tanto, en el futuro mediante una terapia genética podríamos crear seres inteligentes. Pero la pregunta y la controversia, por qué manipular un chimpancé para que sea más y más humano, si ya conocemos el resultado, nosotros. Capítulo 1 Introducción a la Inteligencia Artificial La Inteligencia Artificial (IA o AI de sus siglas en inglés Artificial Intelligence) es una disciplina científica relativamente nueva cuyo objetivo es el diseño e implementación de sistemas artificiales que permitan simular la inteligencia del ser humano, y con ello estar en disposición de realizar la mayoría de nuestras habilidades cognitivas y motoras. Hoy día, la IA está aún lejos de conseguir este reto, no se ha podido alcanzar aún las promesas iniciales, ni los increíbles predicciones de la industria cinematográficas. Seguimos esperando a Terminator, al niño de IA o al sistema operativo romántico de Her; si es que alguna vez llegan, porque parece que el par Hombre-Máquina es mucho más prometedor que el par MáquinaHombre. Parece más cercano conseguir hombres mejorados tecnológica y genéticamente que máquinas que sean tan inteligentes como los hombres. Porque si ya tenemos una gran máquina con un súper diseño, como es el ser humano, por qué diseñar máquinas que nos imite en todos nuestros aspectos. ¿Tendría sentido crear máquinas que sufran o sientan dolor?. Este interesante dilema estará fuera del alcance de este texto. Sin embargo, la IA ha conseguido algunos hitos importantes: se ha desarrollado un software capaz de jugar a los videojuegos de Atari y superar a los humanos en puntos; existen pacientes que han renunciado a sus manos humanas dañadas por manos biónicas, se han creado poemas por computador que han conseguido engañar a los editores de revistas. Además de los famosos casos con gran repercusión mediática como la victoria del supercomputador de IMB frente al campeón mundial de ajedrez o los campeones del concurso Jeopardy de EEUU. O más recientemente, la victoria de una red neuronal creada por la empresa DeepMind, propiedad de Google, consiguió ganar al campeón del mundo del juego GO. Tras este pequeño paréntesis nos toca hablar de la IA como asignatura de Universidad, que es el tema que nos ocupa. La IA es una asignatura multidisciplinar, que podría cubrir una carrera en si misma, pues abarca áreas tan diversas como la robótica o la biomecánica, las ciencias de la computación, la algorítmica, el análisis numérico. Pasando por las Ciencias de la 3 Cognición, hasta llegar a la filosofía o la antropología. Si existiera una llamada Ingeniero en Inteligencia Artificial, algunos de los ramos junto a sus profesores deberían ser: 1. Lógica y sentido común (John McCarthy). 2. Ciencias de la cognición e IA (Marvin Misky). 3. Resolución de problemas y Planificación (Nils J. Nilsson) 4. Computación con percepciones (Lofti Zadeh) 5. Probabilidad y Causalidad (Judea Pearl) 6. Computación Lingüística (Noam Chomsky) 7. Ingeniería del Conocimiento (Edward Feigenbaum) Nosotros como ingenieros informáticos podríamos estar interesados en cualquiera de ellas, pero en un curso de Introducción a la IA y por ser la programación una de las competencias fundamentales de egreso, debemos centrar nuestra atención en las técnicas de programación que permitan crear programas informáticos que muestren un comportamiento autónomo e inteligente, es decir, crearemos sistemas software inteligentes. Y esto lo realizaremos desde una perspectiva novedosa, seremos los creadores de nuestro propio mundo, dotaremos de movimiento a nuestras entidades y las programaremos para que realicen las funciones deseadas. La técnica con la que trabajaremos a lo largo del curso será la Resolución de Problemas y la Planificación de rutas. 1.1. Nuestro concepto de inteligencia La inteligencia es un concepto con mucha controversia y sin una definición consensuada clara. Para definirla con cierto rigor y claridad necesitamos acotarla. Para ello, vamos a crear un mundo artificial (virtual), lo más simple o complejo que queramos, con los medios que dispongamos -y el tiempo, por supuesto, con el que contemos, 4 meses en nuestro caso-. En este mundo virtual habrá entidades, actores, con movimiento, sin movimiento que interactúan unos con otros. Un humano podría interactuar con ese mundo virtual y se le podría encomendar una tarea o problema, capturar recompensas distribuidas por un espacio 2D, por ejemplo. Éste sería capaz de resolverlo, con algo de entrenamiento, llegando incluso a obtener gran habilidad en ese proceso con el transcurso del tiempo. La inteligencia es, en este contexto, la habilidad del humano para capturar de la mejor forma posible las recompensas distribuidas por el escenario. Si las recompensas cambian de posición o, incluso, si surgen nuevos elementos, un humano, tú mismo, sería capaz de adaptarse a ese nuevo escenario, cambiar su comportamiento y volver a resolver el problema de la misma forma. La adaptación es, por tanto, como se ha determinado en la literatura especializada, unas de las cualidades más importantes de los humanos y de su inteligencia, y continua, hoy día, siendo la principal limitación de la IA (conocido desde sus inicios como FRAME PROBLEM). Hoy día, ningún sistema que se haya sido creado por el hombre ha sido capaz de adaptarse de igual forma que un humano, porque cuando crea un robot para patinar difícilmente éste podrá escalar una montaña, requerirá un nuevo diseño, no solo cambiar su programación, también cambiar sus piezas, su aparato locomotor artificial. Por lo tanto, acotando, la IA será para nosotros la creación de un entorno virtual y de unos agentes software que permitan simular a un humano, es decir, sustituirlo para la resolución de problemas en este entorno. Por ejemplo, en el caso que nos ocupa la capacidad de recoger las recompensas distribuidas por el mundo virtual sin ser capturados por adversarios y esquivando los obstáculos. Por tanto el concepto de agente será fundamental para nosotros. 1.2. Diseño basado en agentes El diseño basado en agentes hace uso de un conjunto de algoritmos de persecución y escape, búsqueda de rutas, planificación, toma de decisión para poder implementarlos en un entorno virtual. Los agentes se relacionan directamente con el concepto de NPC (Non-player character), por tanto, en este sentido, los videojuegos y los entornos virtuales se pueden considerar sistemas multiagente. Por tanto, nuestro objetivo es estudiar cómo usar estos agentes para que puedan emplearse en la construcción de entornos virtuales. Existen, al menos, dos metodologías: diseño de agentes basados en comportamiento y diseño de agentes basados en objetivos. Un agente es una entidad que recibe como entrada percepciones y devuelve acciones. Desde el punto de vista de la implementación un agente es un programa que se instala sobre un entorno o arquitectura (mundo virtual). Los entornos con los que vamos a trabajar son entornos totalmente observables, deterministas y discretos. Los agentes se pueden programar en función de sus comportamientos (e.g. atacar, huir). Para realizar una programación basada en comportamientos se deben seguir los siguientes pasos: 1. Estudiar el entorno. Definirlo formalmente mediante parámetros adecuados. 2. Analizar qué es lo qué debe ser capaz de hacer el agente (e.g. moverse, esquivar obstáculos y oponentes, capturar recompensas. 3. Estudiar cuáles son los comportamientos diseñados y cuándo ocurren en función del entorno 4. Asociar a cada comportamiento una funcionalidad. Establecer qué es lo que debe ocurrir como acción asociada a cada comportamiento Las técnicas habituales de implementación son las Máquinas de Estados Finitas para implementar las transiciones de comportamiento y los algoritmos de búsqueda para implementar su funcionalidad asociada. Por ejemplo, en el caso del comecocos podríamos tener 3 comportamientos para los fantasmas: inactivo, persiguiendo y huyendo. Se pasa del estado inactivo al persiguiendo cuando han pasado dos segundos, del persiguiendo al huyendo si el comecocos ha comido una pastilla, del huyendo al persiguiendo cuando han pasado diez segundos, de huyendo al inactivo cuando el comemos está comiendo. Además deberíamos asociar dos funciones a cada comportamiento. la función huir() que deberá implementar la funcionalidad de este comportamiento y la función perseguir() que implementará la funcionalidad del comportamiento persiguiendo. 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. El objetivo es que éste sea lo más sencillo posible de entender para un programador no experto. 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 del Escenario 2D. Plano basado en Celdas Si tomásemos como ejemplo cualquier entorno virtual en dos dimensiones 2D (ver Figura 2.1) podríamos identificar muchos elementos: obstáculos, personajes, premios, vidas, tiempo, puntos acumulados, etc. Un entorno virtual tiene diferentes capas en las cuales aparecen estos elementos. Cada elemento se sitúa en una posición determinada que podrá mantenerse fija o variar. Es decir, habrá objetos inmóviles y objetos móviles. Como objetos inmóviles destacamos obstáculos, barreras, premios, camino, etc. Como objetos móviles podríamos nombrar al personaje o los adversarios. Una posible forma de diseñar tal universo es dividir una determinada área (en cuadrados o rectángulos, por ejemplo ver Figura 2.2) con unas determinadas dimensio7 Figura 2.1: Ejemplo de un Escenario o Entorno 2D de un vídeo juego comercial nes en la cual un objeto (móvil o inmóvil) podrá estar. Nuestro objetivo será que esos objetos que se mueven parezcan inteligentes. 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 decir dónde queremos dibujar el círculo y cuáles serán sus dimensiones. El objetivo de esta sesión es mostrar los recursos disponibles en el lenguaje Java para construir un escenario 2D. Un escenario siempre parte de un plano vacío de celdas que consiste en un conjunto de celdas sobre la cual iremos colocando los elementos, nos centramos en este aspecto. No entramos, por ahora, en aspectos gráficos más avanzados. Queremos conseguir una ventana dividida en celdas o cuadrantes. En cada cuadrante se puede colocar cualquier elemento: un personaje, un obstáculo, una moneda, una trampa, etc, tal y como lo habíamos especificado anteriormente. 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. /* interfaz constantes para variables de configuracion globales */ public interface Constantes { public public public public final final final final int int int int anchuraCelda=64; alturaCelda=64; anchuraMundoVirtual=5; alturaMundoVirtual=3; } La interfaz Constantes la utilizamos para definir parámetros de configuración, anchura y altura del mundo virtual, anchura y altura de las celdas, es decir, variables que se utilizaran en más de una clase y que requerimos sean visibles para ellas. Es conveniente como primera decisión diseño pensar que el tamaño de las celdas sea igual al tamaño de las imágenes que usaremos para crear nuestro mundo virtual. /* paquetes que utilizaremos */ import java.awt.Graphics; import javax.swing.JComponent; /* clase Celda que era de JComponent e implementa Constantes */ public class Celda extends JComponent implements Constantes { //posicion x e y de la Celda public int x; public int y; //constructor public Celda(int x,int y) { this.x=x; this.y=y; } //metodo para dibujar celda, hace uso de drawRect @Override public void paintComponent(Graphics g) { g.drawRect(x,y,anchuraCelda,alturaCelda); } } 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 int anchuraLaberinto,alturaLaberinto;//dimensiones del laberinto public Celda[][] celdas;//las casillas n x m public Laberinto() { celdas=new Celda[anchuraMundoVirtual][alturaMundoVirtual]; //inicializar el array de celdas for(int i=0; i < anchuraMundoVirtual; i++) for ( int j=0 ; j < alturaMundoVirtual ; j++) celdas[i][j]=new Celda(i+(i*anchuraCelda), j+(j*alturaCelda)); //ancho y largo del laberinto this.anchuraLaberinto=anchuraMundoVirtual*anchuraCelda; this.alturaLaberinto=alturaMundoVirtual*alturaCelda; this.setSize(anchuraLaberinto,alturaLaberinto); } @Override public void paintComponent(Graphics g) { for(int i=0; i < anchuraMundoVirtual ; i++) for ( int j=0 ; j < alturaMundoVirtual; 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. /* paquetes que utilizaremos */ import java.awt.Canvas; import java.awt.Color; import java.awt.Graphics; /* la clase Lienzo hereda de Canvas */ public class Lienzo extends Canvas { //para pintar el lienzo public Laberinto laberinto; public Lienzo(){ laberinto=new Laberinto(); //color de fondo this.setBackground(Color.orange); //dimensiones del lienzo this.setSize(laberinto.anchuraLaberinto,laberinto.alturaLaberinto); } //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(). /* paquetes que utilizaremos: -la clase JFrame nos proporciona funcionalidad para crear ventanas -la clase BorderLayout nos proporciona funcionalidad para distribuir los elemtnos graficos */ import java.awt.BorderLayout; import javax.swing.JFrame; /* clase VetanaPrincipal hereda de JFrame para obtener funcionalidad de creacion de ventanas graficas */ public class VentanaPrincipal extends JFrame { //nuestra clase se compone de un lienzo de dibujo (herada de canvas) public Lienzo lienzo; //constructor public VentanaPrincipal() { lienzo=new Lienzo(); this.getContentPane().setLayout(new BorderLayout()); this.getContentPane().add(lienzo); this.setSize(lienzo.getWidth(),lienzo.getHeight()); } } /* paquetes que utilizaremos: -la clase JFrame nos proporciona funcionalidad para crear ventanas*/ import javax.swing.JFrame; /* clase Main */ Figura 2.2: Plano vacío del escenario donde iremos colocando las entidades de nuestro mundo virtual 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 un plano basado en celdas. 2.2. Dando vida al escenario El objetivo de esta sección 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. Esto requiere extender el método paintComponent con el objetivo de distinguir entre estos dos nuevos estados: i) celda seleccionada; ii) celda no seleccionada. public void paintComponent(Graphics g) { g.drawRect(x,y,anchuraCelda,alturaCelda); //si la celda esta seleccionada entonces doy color a la celda if ( celdaSeleccionada ) { g.fillRect(x,y,anchuraCelda,alturaCelda); } } Una vez que hemos extendido este método debemos incorporar uno nuevo que se encargue 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 ya 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. //si el click esta sobre la celda public void celdaSeleccionada(int xp,int yp) { if ( rectanguloCelda.contains(new Point(xp,yp)) ) { if ( celdaSeleccionada ) celdaSeleccionada=false; else celdaSeleccionada=true; } } La clase Celda por tanto quedaría como se muestra a continuación: /* Version Celda.java de la sesion 0.1 */ /* paquetes que utilizaremos */ import import import import java.awt.Graphics; java.awt.Point; java.awt.Rectangle; javax.swing.JComponent; /* clase Celda que era de JComponent e implementa Constantes */ public class Celda extends JComponent implements Constantes { //posicion x e y de la Celda, no cambia durante la ejecucion public int x; public int y; //variable que indica que una celda fue seleccionada //puede cambiar durante la ejecucion public boolean celdaSeleccionada;//estado de la celda public Rectangle rectanguloCelda; //constructor public Celda(int x,int y) { this.x=x; this.y=y; this.celdaSeleccionada=false; rectanguloCelda=new Rectangle(x,y,anchuraCelda,alturaCelda); } //metodo para dibujar celda, hace uso de drawRect @Override public void paintComponent(Graphics g) { g.drawRect(x,y,anchuraCelda,alturaCelda); if ( celdaSeleccionada ) { g.fillRect(x,y,anchuraCelda,alturaCelda); } } //si el click esta sobre la celda public void celdaSeleccionada(int xp,int yp) { if ( rectanguloCelda.contains(new Point(xp,yp)) ) { if ( celdaSeleccionada ) celdaSeleccionada=false; else celdaSeleccionada=true; } } } 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. 2.2.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. /* Version Lienzo.java de la sesion 0.1 */ /* paquetes que utilizaremos */ import import import import import java.awt.Canvas; java.awt.Color; java.awt.Graphics; java.awt.event.MouseAdapter; java.awt.event.MouseEvent; /* la clase Lienzo hereda de Canvas */ 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); //dimensiones del lienzo this.setSize(laberinto.anchuraLaberinto,laberinto.alturaLaberinto); //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 < anchuraMundoVirtual; i++) for ( int j=0 ; j < alturaMundoVirtual ; j++) laberinto.celdas[i][j].celdaSeleccionada(evt.getX(),evt.getY()); } } Las clases VentanaPrincipal y Main quedarían igual. 2.2.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 2.3: 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 2.3 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; } } 2.3. Eligiendo las imágenes de las entidades El objetivo de esta sección es mostrar al alumno como se puede diseñar los actores que van intervenir en el juego. El primer paso consiste en elegir las imágenes que se utilizaran para representar gráficamente a las entidades. Por simplicidad supondremos tres entidades: jugador, adversario, camino y pared. El jugador se manejará desde el teclado por el usuario, el adversario será un NPC, el camino será una celda donde el usuario y el adversario puedan estar o moverse en un momento dado. Para poder usar las imágenes en el proyecto NetBeans crearemos una carpeta denominada imagenes/ donde copiaremos las imágenes que hayamos seleccionado. Recordar que el tamaño de las imágenes debe ser similar al tamaño de las celdas indicadas en la interface Constantes. El proyecto NetBeans debería quedar con la siguiente estructura. Al finalizar esta parte de la sección deberías ser capaz de poder crear un escenario 2D como el de la Figura 2.6. Notar que una vez hayas hecho esto podrás añadir los elementos que quieras y diseñar los laboratorios más o menos complejos, más o menos simples. Esto es una decisión de diseño en la que no entramos por le momento. Para poder realizar esto necesitamos extender la funcionalidad de las clases. En la clase Celda.java debemos implementar la carga de las imágenes. Para poder definir y cargar imágenes haremos uso de los siguientes paquetes: java.io.File, para poder abrir un archivo guardado en memoria secundaria. java.io.IOException, para manejar posibles errores en la apertura de tales archivos. javax.imageio.ImageIO, para poder cargar archivos que sean imágenes, posee un método read() de devuelve una referencia a un objeto del tipo BufferedImage. java.awt.image.BufferedImage, para apuntar a una imagen. Haciendo uso de estos paquetes crearemos los siguientes atributos: public char tipo; public BufferedImage jugador,obstaculo,camino, adversario; El atributo tipo guardará un carácter sobre el tipo de celda que se define. Para ello, debemos incorporar a la interfaz Constantes cuatro constantes, quedando con la siguiente configuración: Figura 2.4: Imágenes elegidas para representar las entidades Figura 2.5: Imágenes elegidas para representar las entidades public interface public final public final public final public final Constantes { int anchuraCelda=64; int alturaCelda=64; int anchuraMundoVirtual=10; int alturaMundoVirtual=7; //Para manejar los tipos de celdas public public public public final final final final char char char char JUGADOR=’J’; CAMINO=’V’; OBSTACULO=’O’; ADVERSARIO=’A’; } Para crear y dibujar una celda del tipo Jugador haremos uso del carácter ’J’, para el camino ’V’ (de vacía), para el obstáculo ’O’ y para el adversario usaremos ’A’. Ahora en el constructor se debe inicializar y cargar los cuatro tipo de imágenes haciendo uso del método read() de la clase +ImageIO. Posteriormente debemos redefinir el método paintComponent para que permita dibujar la imagen en función del tipo de celda que se haya pasado como argumento en el constructor. Por último redefinimos el método celdaSeleccionada para que devuelve un booleano para gestionar posteriormente su selección. Notar que el atributo celdaSeleccionada desaparece porque no lo utilizaremos más. De esta forma, la clase queda con la siguiente implementación: import import import import import import import import java.awt.Graphics; java.awt.Point; java.awt.Rectangle; java.io.File; java.io.IOException; java.awt.image.BufferedImage; javax.imageio.ImageIO; javax.swing.JComponent; public class Celda extends JComponent implements Constantes { public int x; public int y; //nuevos atributos para manejar imagenes public char tipo; public BufferedImage jugador,obstaculo,camino, adversario; //constructor public Celda(int x,int y,char tipo) { this.x=x; this.y=y; this.tipo=tipo; try { jugador = ImageIO.read(new File("images/jugador.png")); obstaculo = ImageIO.read(new File("images/obstaculo.png")); camino = ImageIO.read(new File("images/camino.jpg")); adversario = ImageIO.read(new File("images/adversario.png")); } catch (IOException e) { System.out.println(e.toString()); } } //metodo para dibujar celda, hace uso de drawRect @Override public void paintComponent(Graphics g) { switch(tipo) { case ’J’: g.drawImage(jugador,x,y, null); break; case ’O’: g.drawImage(obstaculo,x,y, this); break; case ’V’: g.drawImage(camino,x,y, this); break; case ’A’: g.drawImage(adversario,x,y, this); break; } } //si el click esta sobre la celda public boolean celdaSeleccionada(int xp,int yp) { return rectanguloCelda.contains(new Point(xp,yp)); } } En la clase Laberinto.java tenemos los cambios necesarios para la inicialización de las variables ya que ahora al definir las celdas debe recibir como tercer argumento el tipo de celda. Por tanto el constructor quedaría de la siguiente forma: public Laberinto() { celdas=new Celda[anchuraMundoVirtual][alturaMundoVirtual]; //inicializar el array de celdas for(int i=0; i < anchuraMundoVirtual; i++) for ( int j=0 ; j < alturaMundoVirtual ; j++) celdas[i][j]=new Celda(i+(i*anchuraCelda), j+(j*alturaCelda),’V’); celdaMovimiento=new Celda(0,0,’J’); celdas[celdaMovimiento.x][celdaMovimiento.y].tipo=’J’; //ancho y largo del laberinto this.anchuraLaberinto=anchuraMundoVirtual*anchuraCelda; this.alturaLaberinto=alturaMundoVirtual*alturaCelda; this.setSize(anchuraLaberinto,alturaLaberinto); } Adicionalmente como el atributo celdaSeleccionada fue descartado debemos redefinir la implementación de los movimientos. Un movimiento se simula pasando la celda actual a tipo ’V’ y la futura celda a tipo ’J’. Se pone el código de moverCeldaArriba() y se deja como ejercicio al lector la definición del resto de métodos: private void moverCeldaArriba(){ if (celdaMovimiento.y > 0 ) { celdas[celdaMovimiento.x][celdaMovimiento.y].tipo=’V’; celdaMovimiento.y=celdaMovimiento.y-1; celdas[celdaMovimiento.x][celdaMovimiento.y].tipo=’J’; } } En la clase Lienzo.java debemos modificar la implementación del método activarCelda. Ahora debemos saber cuándo se pulsó el botón derecho o el izquierdo. Si se pulsa el botón izquierdo entonces el tipo de celda será un adversario, si se pulsa el botón el botón derecho entonces el tipo de celda será un obstáculo. Para conseguir esta funcionalidad debemos emplear la función evt.getModifiers() haciendo un “and” con InputEvent.BUTTON1_MASK, si el resultado es igual a InputEvent.BUTTON1_MASK entonces se pulsó el botón derecho, en otro caso el izquierdo. private void activarCelda(MouseEvent evt) { for(int i=0; i < anchuraMundoVirtual; i++) { for ( int j=0 ; j < alturaMundoVirtual ; j++) { if ( laberinto.celdas[i][j].celdaSeleccionada(evt.getX(),evt.getY()) ) { //Para saber si se pulso if((evt.getModifiers() & InputEvent.BUTTON1_MASK) == InputEvent.BUTTON1_MA System.out.println("Boton derecho - Poner obstaculo"); laberinto.celdas[i][j].tipo=’O’; }else { System.out.println("Boton izquierdo - Poner adversario"); laberinto.celdas[i][j].tipo=’A’; } } } } } La última modificación tiene que ver con el inicio de ventana. Si no se ha percatado aún, al ejecutar las primeras veces la aplicación los eventos no se consideran hasta que el usuario hace click sobre la ventana. Para solucionar y focalizar los escuchadores sobre la ventana principal debemos hacer uso de dos métodos lienzo.setFocusable(true); y lienzo.requestFocus();. De esta forma la clase VentanaPrincipal.java quedaría con la siguiente implementación. import java.awt.BorderLayout; import java.io.IOException; import javax.swing.JFrame; public class VentanaPrincipal extends JFrame { //nuestra clase se compone de un lienzo de dibujo (herada de canvas) public Lienzo lienzo; //constructor public VentanaPrincipal() { lienzo=new Lienzo(); lienzo.setFocusable(true); lienzo.requestFocus(); this.getContentPane().setLayout(new BorderLayout()); this.getContentPane().add(lienzo); this.setSize(lienzo.getWidth()*2,lienzo.getHeight()*2); } } Si todo es correcto usted debería poder crear el primer diseño de su mundo virtual, similar a como aparece en la Figura 2.6. 2.3.1. Eliminando el parpadeo Uno de los problemas más comunes cuando se desarrollan animaciones en Java es el parpadeo (ver Figura 2.7). El parpadeo es un problema bastante Figura 2.6: Primer diseño de nuestro mundo virtual común en animación y se debe a dos factores: i) el fondo de la animación se limpia por defecto con el color de fondo antes de que se llame al método pintar; ii) los métodos de pintar en pantalla son costosos y el sistema suele tardar más en dibujar la animación que el sistema en refrescar la pantalla. Se pueden emplear dos técnicas para eliminar el parpadeo. La primera consiste en sobre escribir el método update(Graphics g). La segunda, que veremos en la siguiente sección, consiste en implementar la técnicas de double buffering o doble buffer. Usar el método update(Graphics g) es la única forma de prevenir que el fondo del componente se limpie justo antes de que se dibuje el componente. Es decir, con update(Graphics g) se fuerza a que el componente se dibuje y después se limpie el fondo. En nuestro caso, deberemos modificar los protocolos de pintado de las clases Celda.java, Laberinto.java y Lienzo.java, empleado tal método. En las tres clases procederemos de la misma forma, crearemos un método update(Graphics g), llevaremos el código que aparece en los métodos +paintComponent y paint a este método y en su lugar llamaremos al método update. Por ejemplo el código de pintado para las tres clases sería el siguiente: // Código de pintado de la clase Celda.java @Override public void update(Graphics g) { switch(tipo) { case ’J’: g.drawImage(jugador,x,y, this); break; case ’O’: g.drawImage(obstaculo,x,y, this); break; case ’V’: g.drawImage(camino,x,y, this); break; case ’A’: g.drawImage(adversario,x,y, this); break; } } Figura 2.7: La parte derecha se muestra parte del fondo sin que las celdas aparezcan como ocurre en la parte izquierda @Override public void paintComponent(Graphics g) { update(g); } // Código de pintado de la clase Laberinto.java @Override public void update(Graphics g) { for(int i=0; i < anchuraMundoVirtual ; i++) for ( int j=0 ; j < alturaMundoVirtual; j++) celdas[i][j].update(g); } @Override public void paintComponent(Graphics g) { update(g); } //Codigo de pintado de la clase Lienzo.java @Override public void update(Graphics g) { laberinto.paintComponent(g); } @Override public void paint(Graphics g) { update(g); } Figura 2.8: Fondo del mundo virtual 2.3.2. Imagen de fondo Es habitual emplear una imagen de fondo para dar más realismo al videojuego. Por ejemplo, supongamos que queremos emplear una pecera como escenario del videojuego. Podríamos elegir una imagen que la simule (ver Figura 2.8). Para incorporar este fondo debemos modificar la clase Lienzo.java haciendo uso de las funciones de carga y dibujo de imágenes. Por tanto el código de la clase Lienzo quedaría cómo sigue (solo se muestra las partes que cambian). Note que la imagen debería tener una dimensión de ancho de anchoCelda*anchoMundoVirtual y una altura de anchoCelda*anchoMundoVirtual para que los movimientos de teclado alcancen a todo el fondo. Para este caso se emplearon las siguiente definición de constantes y una imagen jpeg de ancho 1280 y largo de 640. public public public public final final final final int int int int anchuraCelda=64; alturaCelda=64; anchuraMundoVirtual=20; alturaMundoVirtual=10; public class Lienzo extends Canvas implements Constantes{ public Laberinto laberinto; public Image fondo; public Lienzo() { laberinto=new Laberinto(); try { fondo = ImageIO.read(new File("images/fondo.jpg")); } catch (IOException e) { System.out.println(e.toString()); } [...] } @Override public void update(Graphics g) { g.drawImage(fondo,0,0, null); laberinto.paintComponent(g); } [...] } El siguiente paso consiste en comentar la parte de la clase Celda.java donde se gestiona las celdas de tipo ’V’. //constructor public Celda(int x,int y,char tipo) { this.x=x; this.y=y; this.tipo=tipo; try { jugador = ImageIO.read(new File("images/jugador.png")); obstaculo = ImageIO.read(new File("images/obstaculo.png")); //camino = ImageIO.read(new File("images/camino.jpg")); adversario = ImageIO.read(new File("images/adversario.png")); } catch (IOException e) { System.out.println(e.toString()); } } //metodo llamado cuando repaint @Override public void update(Graphics g) { switch(tipo) { case ’J’: g.drawImage(jugador,x,y, null); break; case ’O’: g.drawImage(obstaculo,x,y, this); break; //case ’V’: g.drawImage(camino,x,y, this); break; case ’A’: g.drawImage(adversario,x,y, this); break; } } Otra opción muy conveniente que se puede utilizar en la etapa de diseño es mostrar las celdas por encima del fondo para monitorizar los movimientos de las entidades (ver Figura 2.10). Para hacer esto hay que seguir dos pasos: i) definir un color que sea transparente; ii) en el caso ’V’ cambiar el dibujado de imagen por un rectángulo que se rellene con este color. El color de fondo lo podemos definir en la interfaz Constantes. import java.awt.Color; public interface Constantes { [...] public final int ALFA=127; public final Color COLORFONDO=new Color(153,217,234,ALFA); } La parte de código del método update de la clase Celda quedaría como sigue: @Override public void update(Graphics g) { switch(tipo) { case ’J’: g.drawImage(jugador,x,y, null); break; case ’O’: g.drawImage(obstaculo,x,y, this); break; case ’V’: g.setColor(COLORFONDO); g.fillRect(x, y,anchuraCelda,alturaCelda); break; case ’A’: g.drawImage(adversario,x,y, this); break; } } El problema de esta solución es que se repintan muchos elementos por lo que necesitaremos implementar el double buffering. Para implementar esta técnica es necesario modificar el protocolo de pintado de la clase Lienzo. Vamos a utilizar dos nuevos atributos en dicha clase. El primero será de tipo Graphics y lo utilizaremos como buffer de contexto grafico. El segundo será la imagen donde pintaremos el fondo del contexto. //clase Lienzo //para implementar el doble buffer public Graphics graficoBuffer; public Image imagenBuffer; Ahora modificamos el método update(Graphics g) para implementar el doble buffer. Primero, si el buffer no fue creado, se inicializa el buffer gráfico mediante la imagen auxiliar: creamos una nueva imagen y asignamos su contexto gráfico al nuevo buffer. Si el buffer gráfico ya fue creado entonces modificamos su color con el fondo del contexto gráfico actual, pintamos un rectángulo entero y dibujamos la imagen de fondo. Entonces se llama al método update con el contexto gráfico del buffer. Después del update pintamos la imagenBuffer (ver Figura 2.9). Figura 2.9: Explicación del doble buffer Override public void update(Graphics g) { //inicialización del buffer gráfico mediante la imagen if(graficoBuffer==null){ imagenBuffer=createImage(this.getWidth(),this.getHeight()); graficoBuffer=imagenBuffer.getGraphics(); } //volcamos color de fondo e imagen en el nuevo buffer grafico graficoBuffer.setColor(getBackground()); graficoBuffer.fillRect(0,0,this.getWidth(),this.getHeight()); graficoBuffer.drawImage(fondo, 0, 0, null); laberinto.update(graficoBuffer); //pintamos la imagen previa g.drawImage(imagenBuffer, 0, 0, null); } 2.4. 2.4.1. Funcionalidades adicionales: música y sprites Música Para incorporar música a nuestro proyecto Java vamos a emplear tres paquetes: javax.sound, java.net y java.io. Lo primero que debemos hacer será seleccionar un archivo .wav y copiarlo en una carpeta \musica a la altura de \src e \imagenes. En segundo lugar crearemos una clase llamada HiloMusica.java que heredará de la clase Thread. La clase Thread, que veremos con más profundidad más adelante, permita al programador crear hilos de ejecución. Un hilo de ejecución es una simplemente una porción de código, que aparecerá en un método run(), que se puede ejecutar de forma concurrente o paralela sobre el computador. Note que hay una pequeña diferente entre estos conceptos. Hablamos de concurrencia cuando un mismos procesador o unidad de procesamiento se comparte entre varios hilos. Se Figura 2.10: Mundo virtual con sobre escritura del método update e implementación doble buffer habla de paralelismo cuando cada hilo se ejecuta en una unidad de procesamiento diferente al contar el computador con varias de estas unidades (lo que denominada como núcleo, del inglés core). La clase HiloMusica.java estará formada por tres atributos. Uno de tipo Clip para manejar la canción, uno de tipo string para almacenar la ruta del mismo y uno de tipo int para establecer el número de veces que queremos que se repita la canción maneja por Clip. import import import import import import import import java.io.IOException; java.net.MalformedURLException; java.net.URL; javax.sound.sampled.AudioInputStream; javax.sound.sampled.AudioSystem; javax.sound.sampled.Clip; javax.sound.sampled.LineUnavailableException; javax.sound.sampled.UnsupportedAudioFileException; public class HiloMusica extends Thread { public Clip cancion; public String ruta; public int repeticiones; public HiloMusica(String ruta,int repeteciones) { this.ruta=ruta; this.repeticiones=repeteciones; } @Override public void run() { try { URL url = new URL(ruta); AudioInputStream audioIn = AudioSystem.getAudioInputStream(url); cancion = AudioSystem.getClip(); cancion.open(audioIn); cancion.loop(repeticiones); }catch(MalformedURLException murle) { System.out.println(murle.toString()); }catch(UnsupportedAudioFileException | IOException | LineUnavailableException e) { System.out.println(e.toString()); } }//fin del método run } Para abrir un archivo de este tipo se debe proporcionar la ruta absoluta. Por tanto vamos a añadir a la clase Constantes la ruta global proyecto haciendo uso de System.getProperty(‘‘user.dir’’) que nos proporciona la ruta absoluta del proyecto. Añadimos la siguiente linea de código a la clase Constantes.java. public final String RUTA="file:///"+System.getProperty( "user.dir" ); Por último en la clase VentanaPrincipal.java inicializamos un objeto de tipo HiloMusica y lo lanzamos llamando al método run() que ejecuta el código necesario para lanzar la canción. public class VentanaPrincipal extends JFrame implements Constantes{ [...] public HiloMusica player; //constructor public VentanaPrincipal() { [...] player=new HiloMusica(RUTA+"/music/klemenzza.wav",2); player.run(); } 2.4.2. Sprites Los sprites (“duendes”) se emplean en videojuegos para crear los gráficos jugadores y adversarios. Se utilizan para producir una simulación de movimiento, como un personaje corriendo, alguna expresión de la cara o algún movimiento corporal (ver Figura 6.1). Figura 2.11: Ejemplos Sprites Para emplearlos se procede de forma simular a la carga de imágenes. Sin embargo ahora debemos recorrer la imagen e ir cargando de forma individual cada imagén por separado. Normalmente el proceso consiste en leer subimágenes e ir almacenándolas en un array (ver Figura 2.12) Figura 2.12: Proceso de lectura de un sprites y su almacenamiento de sus subimágenes en un array Antes de nada debemos ajustar el tamaño de las celdas al tamaño de los sprites. En este ejemplo usamos un sprites cuyas subimágenes son de 32 por 32. Debemos ir a Constantes.java y cambiar el tamaño. A continuación, para implementar el proceso de carga de las subimágenes debemos crear tres atributos en la clase Celda.java. public int indexSprite; public BufferedImage sprites[],imagenSprites; El atributo indexSprite lo empleamos para movernos por el array sprites. El atributo sprites almacenará las imágenes. EL atributo imagenSprites será el que almacena la imagen de los sprites. Así, el constructor de la clase Celda queda como sigue: public Celda(int x,int y,char tipo) { this.x=x; this.y=y; this.tipo=tipo; indexSprite=2; //indice que corresponde a una subimagen de frente try { jugador = ImageIO.read(new File("images/jugador.png")); obstaculo = ImageIO.read(new File("images/obstaculo.png")); //camino = ImageIO.read(new File("images/camino.jpg")); adversario = ImageIO.read(new File("images/adversario.png")); //gestion de sprites //cargo la imagen de grupo de imagenes imagenSprites = ImageIO.read(new File("images/snake.png")); //creo una array de 4 x 3 sprites = new BufferedImage[4 * 3]; //lo recorro separando las imagenes for(int i = 0; i < 3; i++) { for(int j = 0; j < 4; j++) { sprites[(i * 4) + j] = imagenSprites.getSubimage(i * anchuraCelda, j * alturaCelda, anchuraCelda, alturaCelda); } } } catch (IOException e) { System.out.println(e.toString()); } } Una vez cargadas las subimágenes debemos modificar el atributo indexSprite cada vez que se haga un movimiento. El procedimiento consiste en modificar su valor en función de si se pulsa arriba, abajo, izquierda o derecha. Si se pulsa arriba indexSprite debe apuntar a una subimagen donde el personaje aparezca de espaldas, si es abajo a una subimagen de frente, si es izquierda a una subimagen donde el personaje aparezca de perfil izquierdo y si se pulsa derecha, una subimagen de perfil derecho. Estas modificaciones se realizan en la clase laberinto en los métodos de movimientos. Así, estos métodos quedarían como sigue: private void moverCeldaArriba(){ if (celdaMovimiento.y > 0 ) { celdas[celdaMovimiento.x][celdaMovimiento.y].tipo=’V’; celdaMovimiento.y=celdaMovimiento.y-1; celdas[celdaMovimiento.x][celdaMovimiento.y].tipo=’J’; celdas[celdaMovimiento.x][celdaMovimiento.y].indexSprite=0; } } private void moverCeldaAbajo(){ if (celdaMovimiento.y+1 < alturaMundoVirtual ) { celdas[celdaMovimiento.x][celdaMovimiento.y].tipo=’V’; celdaMovimiento.y=celdaMovimiento.y+1; celdas[celdaMovimiento.x][celdaMovimiento.y].tipo=’J’; celdas[celdaMovimiento.x][celdaMovimiento.y].indexSprite=2; } } private void moverCeldaIzquierda(){ if (celdaMovimiento.x > 0 ) { celdas[celdaMovimiento.x][celdaMovimiento.y].tipo=’V’; celdaMovimiento.x=celdaMovimiento.x-1; celdas[celdaMovimiento.x][celdaMovimiento.y].tipo=’J’; celdas[celdaMovimiento.x][celdaMovimiento.y].indexSprite=1; } } private void moverCeldaDerecha(){ if (celdaMovimiento.x+1 < anchuraMundoVirtual ) { celdas[celdaMovimiento.x][celdaMovimiento.y].tipo=’V’; celdaMovimiento.x=celdaMovimiento.x+1; celdas[celdaMovimiento.x][celdaMovimiento.y].tipo=’J’; celdas[celdaMovimiento.x][celdaMovimiento.y].indexSprite=3; } } Capítulo 3 Animación básica 3.1. Dando vida al adversario El objetivo de este capítulo es explicar cómo se pueden animar las entidades, esto es, proporcionales movimiento. Para nosotros animar será decirle a la entidad que pasos debe dar. Para comenzar sólo animaremos a los adversarios y los movimientos serán de derecha a izquierda simulando así una avance. Al llegar al final el adversario desaparecerá y de forma aleatoria aparece en el lado derecho de nuevo, así sucesivamente hasta que termine la partida. Esta funcionalidad puede implementarse en Java con Hilos de Ejecución. Informalmente, y en este contexto, un hilo de ejecución será una tarea (bloque de código) que se ejecuta cada cierto tiempo y que puede hacerlo de forma concurrente o paralela junto a otros hilos de ejecución. Estos hilos de ejecución se denominan TimerTask. Un TimerTask puede lanzarse cada X segundos. En cada lanzamiento se ejecuta el código situado en el método run(). Por tanto mi adversario deberá heredar de esta clase para poder moverse por el escenario. La implementación de una clase Adversario.java sería la siguiente: import java.util.TimerTask; public class Adversario extends TimerTask implements Constantes{ public Laberinto laberinto; public Celda adversario; public Adversario(Laberinto laberinto) { this.laberinto=laberinto; adversario=new Celda(anchuraMundoVirtual-1, numeroAleatorio(0,alturaMundoVirtual-1),’A’); laberinto.celdas[adversario.x][adversario.y].tipo=’A’; } public void moverAdversario(){ 35 if (adversario.x > 0 ) { laberinto.celdas[adversario.x][adversario.y].tipo=’V’; adversario.x=adversario.x-1; laberinto.celdas[adversario.x][adversario.y].tipo=’A’; }else { laberinto.celdas[adversario.x][adversario.y].tipo=’V’; adversario.x=anchuraMundoVirtual-1; adversario.y=numeroAleatorio(0,alturaMundoVirtual-1); laberinto.celdas[adversario.x][adversario.y].tipo=’A’; } } @Override public void run() { moverAdversario(); laberinto.lienzoPadre.repaint(); } } Necesitamos un método numeroAleatorio que nos de un número cualquier entre n y m. public interface Constantes { [...] default int numeroAleatorio(int minimo, int maximo) { Random random = new Random(); int numero_aleatorio = random.nextInt((maximo - minimo) + 1) + minimo; return numero_aleatorio; } } La clase Lienzo quedaría como sigue. Notar que se ha eliminado todo lo relacionado con eventos de ratón. public class Lienzo extends Canvas implements Constantes{ //sin cambios (resto de atributos) //Para animacion basica public Adversario adversario,adversario2; public Timer lanzadorTareas; public Lienzo() { laberinto=new Laberinto(this); adversario=new Adversario(laberinto); adversario2=new Adversario(laberinto); try { fondo = ImageIO.read(new File("images/fondo.jpg")); } catch (IOException e) { System.out.println(e.toString()); } //dimensiones del lienzo this.setSize(laberinto.anchuraLaberinto,laberinto.alturaLaberinto); //escuchador eventos de teclado addKeyListener(new java.awt.event.KeyAdapter() { @Override public void keyPressed(KeyEvent evt) { laberinto.moverCelda(evt); repaint(); } }); lanzadorTareas=new Timer(); lanzadorTareas.scheduleAtFixedRate(adversario,0,1000); lanzadorTareas.scheduleAtFixedRate(adversario2,0,500); } @Override public void update(Graphics g) { //sin cambios } //metodo llamada la primera vez que se pinta @Override public void paint(Graphics g) { //sin cambios } } El último cambio lo debemos hacer en Laberinto, pasarle como argumento el lienzo para que desde adversario, que se mueve por un laberinto, se pueda llamar al repintar de lienzo. public class Laberinto extends JComponent implements Constantes { public public public public int anchuraLaberinto,alturaLaberinto;//dimensiones del laberinto Celda[][] celdas;//las casillas n x m Celda celdaMovimiento; Lienzo lienzoPadre; public Laberinto(Lienzo lienzoPadre) { this.lienzoPadre=lienzoPadre; //resto igual } //resto igual } 3.1.1. Practicando mediante dos tareas En la sección previa aprendimos a mover a los adversarios de derecha a izquierda. En este mismo proyecto se pide realizar lo siguiente. Primero, diseñar e implementar una clase Jugador.java que sea un hilo de ejecución. Segundo, crear los métodos necesarios para mover al jugador de izquierda a derecha. Si se produce una colisión entre el jugador y el adversario deberían salir en sentido contrario al que iban al momento de chocar. El primer paso consiste en construir una clase Jugador.java que herede de TimerTask. La clase Jugador cuenta con un atributo direccion que indica si se mueve hacia la izquierda o hacia la izquierda. Por tanto, contará con dos métodos de movimiento moverJugadorDerecha y moverJugadorIzquierda. El algoritmo de movimiento a la derecha es el siguiente: 1. Si al mover a la derecha no es adversario me muevo normal 2. Si al mover a la derecha hay un adversario cambio de direccion El código de la clase Jugador queda como sigue. import java.util.TimerTask; public class Jugador extends TimerTask implements Constantes{ public Laberinto laberinto; public Celda jugador; public int direccion;//para saber hacia donde me muevo public Jugador(Laberinto laberinto) { this.laberinto=laberinto; jugador=new Celda(0,numeroAleatorio(0,alturaMundoVirtual-1),’J’); laberinto.celdas[jugador.x][jugador.y].tipo=’J’; direccion=0;//mover derecha } public void moverJugadorDerecha(){ if (jugador.x < anchuraMundoVirtual-1 ) { //si al mover a la derecha no es adversario //me muevo normal if ( laberinto.celdas[jugador.x+1][jugador.y].tipo!=’A’ ) { laberinto.celdas[jugador.x][jugador.y].tipo=’V’; jugador.x=jugador.x+1; laberinto.celdas[jugador.x][jugador.y].tipo=’J’; //y si no cambio de direccion a la izquierda => direccion=1 }else direccion=1; }else { //para re-aparecer laberinto.celdas[jugador.x][jugador.y].tipo=’V’; jugador.x=0; jugador.y=numeroAleatorio(0,alturaMundoVirtual-1); laberinto.celdas[jugador.x][jugador.y].tipo=’J’; } } public void moverJugadorIzquierda(){ //si no estoy al inicio if (jugador.x > 0 ) { //compruebo que al moverme a la izquierda no //este el adversario if (laberinto.celdas[jugador.x-1][jugador.y].tipo!=’A’){ laberinto.celdas[jugador.x][jugador.y].tipo=’V’; jugador.x=jugador.x-1; laberinto.celdas[jugador.x][jugador.y].tipo=’J’; //en caso contrario cambio de direccion }else direccion=0; }else { laberinto.celdas[jugador.x][jugador.y].tipo=’V’; jugador.x=anchuraMundoVirtual-1; jugador.y=numeroAleatorio(0,alturaMundoVirtual-1); laberinto.celdas[jugador.x][jugador.y].tipo=’J’; } } @Override public void run() { if (direccion==0) moverJugadorDerecha(); else moverJugadorIzquierda(); laberinto.lienzoPadre.repaint(); } } Notar la implementación del método, ahora, si direccion es igual 0 entonces el jugador se movera a la derecha, en caso contrario se moverá a la izquierda. La implementación de la clase Adversario sería análoga. Recuerde que la clase Lienzo debería modicarse para lanzar al jugador. Capítulo 4 Interfaces gráficas en Java En este capítulo veremos una introducción al paquete Swing de Java mediante el cual se pueden construir interfaces de usuario rápida y fácilmente. Presentaremos sus principales características y veremos detalladamente todos los conceptos que necesita conocer para poder utilizar y entender los componentes Swing de forma efectiva. Vamos a mostrar algunos de los componentes más utilizados de Swing y explicaremos el concepto de Contenedor (Container), central en la lógica de ventanas de Java. Para ilustrarlo, usaremos el programa “Hola Mundo Gráfico” y un conversor de temperatura. La aplicación crea cuatro componentes Swing: un frame, o ventana principal (JFrame) un panel, algunas veces llamado panel (JPanel) un botón (JButton) una etiqueta (JLabel) 4.1. Java Foundation Classes ¿Qué son el JFC y Swing? JFC es la abreviatura de Java Foundation Classes, que comprende un grupo de características para ayudar a construir interfaces gráficas de usuario (GUIs). Los componentes Swing incluyen todo tipo de elementos gráficos, desde botones hasta paneles de separación o tablas de datos. Swing da Soporte para definir el aspecto y el comportamiento: ofrece una amplia selección de aspectos y comportamientos para cada componente (Por ejemplo, el mismo programa puede usar el Aspecto y Comportamiento Java o el Aspecto y Comportamiento Windows). Existe además una API de Accesibilidad permite crear interfaces para asistencia al usuario como lectores de pantalla o interfaz Braille para obtener información desde el interface de usuario. Por otro lado, la API 2D de Java permite a los desarrolladores incorporar fácilmente gráficos 2D de alta calidad, texto, e imágenes en aplicaciones. 4.2. Menu Inicial Empleamos el menú inicial del juego YADY (http://www.youractionsdefine.you/y 41 Constantes.java import java.awt.Dimension; import java.awt.Toolkit; public interface Constantes { public public public public public public int FUENTE_SIZE=12; int CELDA_SIZE=32; int N=31; int M=21; Dimension SCREEN_SIZE = Toolkit.getDefaultToolkit().getScreenSize(); String RUTA_DIRECTORIO=System.getProperty( "user.dir" ); } Lienzo.java import java.awt.Canvas; import java.awt.Graphics; public class Lienzo extends Canvas { public Lienzo() {} @Override public void update(Graphics g) {} } PanelDeJuego.java import java.awt.BorderLayout; import javax.swing.JPanel; public class PanelDeJuego extends JPanel implements Constantes { public Lienzo lienzo; public String nombre, email; public boolean castellano; public boolean toShowFeedback; public PanelDeJuego(String nombre,String email,boolean castellano, boolean toShowFeedback) { this.nombre=nombre; this.email=email; this.castellano=castellano; this.toShowFeedback=toShowFeedback; setLayout(new BorderLayout()); setSize((CELDA_SIZE*M)+CELDA_SIZE, (CELDA_SIZE*N)+CELDA_SIZE); lienzo=new Lienzo(); lienzo.setFocusable(true); lienzo.requestFocus(); add(lienzo); } } PanelMenuInicial.java import import import import import import import import import import import import import import import import import import import java.awt.Color; java.awt.Dimension; java.awt.Font; java.awt.Graphics; java.awt.event.ActionEvent; java.awt.image.BufferedImage; java.io.File; java.io.IOException; java.util.logging.Level; java.util.logging.Logger; javax.imageio.ImageIO; javax.swing.ImageIcon; javax.swing.JButton; javax.swing.JCheckBox; javax.swing.JFrame; javax.swing.JLabel; javax.swing.JOptionPane; javax.swing.JPanel; javax.swing.JTextField; public class PanelMenuInicial extends JPanel implements Constantes{ public public public public public public public public public public JButton comenzar,salir; JCheckBox with_feedback, with_feedback2, castellano, ingles; JLabel nombre; JTextField campo_nombre; JLabel email; JTextField campo_email; ImageIcon icono_mundo_klemenzza; String[] tipos_letra; JFrame ventana_principal; boolean toShow_feedback; public PanelMenuInicial(JFrame ventana_principal) { this.ventana_principal=ventana_principal; this.setName("YADY Computer Game"); this.setLayout(null); this.setSize(SCREEN_SIZE.width,SCREEN_SIZE.height); toShow_feedback=true; nombre=new JLabel("N A M E : "); nombre.setFont(new Font("Times New Roman",Font.BOLD,40)); nombre.setForeground(Color.orange); nombre.setBounds(20,20,400,200); campo_nombre=new JTextField(""); campo_nombre.setFont(new Font("Times New Roman",Font.BOLD,40)); campo_nombre.setForeground(Color.LIGHT_GRAY); campo_nombre.setCaretColor(Color.orange); campo_nombre.setBounds(240,100,700,50); campo_nombre.setOpaque(false); email=new JLabel("E M A I L : "); email.setFont(new Font("Times New Roman",Font.BOLD,40)); email.setForeground(Color.orange); email.setBounds(20,80,400,200); with_feedback=new JCheckBox("Intelligent Feedback",toShow_feedback); with_feedback.setFont(new Font("Times New Roman",Font.BOLD,30)); with_feedback.setForeground(Color.LIGHT_GRAY); with_feedback.setBounds(60,260,800,40); with_feedback.setOpaque(false); with_feedback.addActionListener(this::seleccionarFeedbackInteligente); with_feedback2=new JCheckBox("Final Linguistic Feedback via email",false); with_feedback2.setFont(new Font("Times New Roman",Font.BOLD,30)); with_feedback2.setForeground(Color.LIGHT_GRAY); with_feedback2.setBounds(60,320,1000,40); with_feedback2.setOpaque(false); castellano=new JCheckBox("Castellano",false); castellano.setFont(new Font("Times New Roman",Font.BOLD,20)); castellano.setForeground(Color.orange); castellano.setBounds(20,0,150,100); castellano.setOpaque(false); castellano.addActionListener(this::seleccionarCastellano); ingles=new JCheckBox("English",true); ingles.setFont(new Font("Times New Roman",Font.BOLD,20)); ingles.setForeground(Color.orange); ingles.setBounds(200,0,150,100); ingles.setOpaque(false); ingles.addActionListener(this::seleccionarIngles); campo_email=new JTextField(""); campo_email.setFont(new Font("Times New Roman",Font.BOLD,40)); campo_email.setForeground(Color.LIGHT_GRAY); campo_email.setCaretColor(Color.orange); campo_email.setBounds(240,160,700,50); campo_email.setOpaque(false); comenzar=new JButton("P R E S S S T A R T"); comenzar.setBounds(700,620,310,100); comenzar.setOpaque(false); comenzar.setFont(new Font("Times New Roman",Font.BOLD,20)); comenzar.setBackground(Color.BLACK); comenzar.setForeground(Color.orange); comenzar.setHorizontalTextPosition(JButton.CENTER); comenzar.setVerticalTextPosition(JButton.CENTER); comenzar.addActionListener(this::pulsarBotonComenzar); salir=new JButton("E X I T"); salir.setBounds(200,620,310,100); salir.setOpaque(false); salir.setFont(new Font("Times New Roman",Font.BOLD,20)); salir.setBackground(Color.BLACK); salir.setForeground(Color.ORANGE); salir.setHorizontalTextPosition(JButton.CENTER); salir.setVerticalTextPosition(JButton.CENTER); salir.addActionListener(this::pulsarBotonSalir); add(comenzar); add(salir); add(nombre); add(campo_nombre); add(email); add(campo_email); add(with_feedback); add(with_feedback2); add(castellano); add(ingles); } public void pulsarBotonComenzar(ActionEvent e) { if ( campo_nombre.getText().isEmpty() && campo_email.getText().isEmpty() JOptionPane.showMessageDialog(null,"DEBES INTRODUCIR TU NOMBRE", "YADY Computer Game",JOptionPane.PLAIN_MESSAGE); }else { JOptionPane.showMessageDialog(null,"LANZAR EL VIDEO JUEGO", "YADY Computer Game",JOptionPane.PLAIN_MESSAGE); if ( castellano.isSelected()){ JOptionPane.showMessageDialog(null,"EN IDIOMA CASTELLANO", "YADY Computer Game",JOptionPane.PLAIN_MESSAGE); } if ( with_feedback2.isSelected()) { JOptionPane.showMessageDialog(null,"Y CON FEEDBACK ACTIVADO", "YADY Computer Game",JOptionPane.PLAIN_MESSAGE); } } } public void pulsarBotonSalir(ActionEvent e) { System.exit(0); } public void seleccionarCastellano(ActionEvent e) { JOptionPane.showMessageDialog(null, "SE SELECCIONO CASTELLANO", "YADY Computer Game",JOptionPane.PLAIN_MESS ingles.setSelected(false); nombre.setText("NOMBRE:"); email.setText("CORREO:"); with_feedback.setText("Activar FeedBack Inteligente"); with_feedback2.setText("Enviar feedback por correo electronico"); comenzar.setText("COMENZAR"); salir.setText("SALIR"); } public void seleccionarIngles(ActionEvent e) { JOptionPane.showMessageDialog(null,"SE SELECCIONO INGLES", "YADY Computer Game", JOptionPane.PLAIN_MESSAGE); castellano.setSelected(false); nombre.setText("N A M E:"); email.setText("E M A I L:"); with_feedback.setText("Intelligent Feedback"); with_feedback2.setText("Linguistic Feedback via email"); comenzar.setText("P R E S S S T A R T"); salir.setText("E X I T"); } public void seleccionarFeedbackInteligente(ActionEvent e) { JOptionPane.showMessageDialog(null,"SE SELECCIONO FEEDBACK INTELIGENTE", "YADY Computer Game",JOptionPane.PLAIN_MESSAGE); toShow_feedback = with_feedback.isSelected(); } @Override public void paintComponent(Graphics g) { Dimension d=getSize(); BufferedImage fondo=null; try { fondo = ImageIO.read(new File(RUTA_DIRECTORIO+"/imagenes/fondo.jpg")); } catch (IOException ex) { Logger.getLogger(PanelMenuInicial.class.getName()).log(Level.SEVERE, } g.drawImage(fondo,0,0, d.width, d.height,null); super.paintComponents(g); } } 4.3. Panel de Configuración para cambiar velocidad personajes Es habitual que en tiempo de ejecución se necesite modificar los parámetros iniciales de una aplicación. Por ejemplo en el proyecto en el que estamos trabajando que el usuario pueda cambiar la velocidad de las entidades. VentanaPrincipal.java import java.awt.BorderLayout; import javax.swing.JFrame; import javax.swing.JSplitPane; public class VentanaPrincipal extends JFrame implements Constantes{ public JSplitPane panelSeparador;//panel separador public PanelDeJuego panelJuego;//panel de juego (contiene lienzo) public PanelConfiguracion panelConfiguracion;//panel configuracion public VentanaPrincipal() { panelSeparador=new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); panelSeparador.setOneTouchExpandable(true);//lo puedo desplazar panelJuego=new PanelDeJuego(); panelConfiguracion=new PanelConfiguracion(panelJuego); panelSeparador.setLeftComponent(panelJuego); panelSeparador.setRightComponent(panelConfiguracion); panelSeparador.setDividerLocation(panelJuego.getWidth()+20); panelSeparador.setDividerSize(8); getContentPane().setLayout(new BorderLayout()); getContentPane().add(panelSeparador,BorderLayout.CENTER); this.setSize(SCREEN_SIZE.width-50,SCREEN_SIZE.height-50); } } PanelDeJuego.java import java.awt.BorderLayout; import javax.swing.JPanel; public class PanelDeJuego extends JPanel implements Constantes { public Lienzo lienzo; public PanelDeJuego() { this.setLayout(new BorderLayout()); lienzo=new Lienzo(); lienzo.setFocusable(true); lienzo.requestFocus(); this.add(lienzo); this.setSize(lienzo.getWidth(),lienzo.getHeight()); } } PanelConfiguracion.java import import import import import import import java.awt.BorderLayout; java.awt.Color; javax.swing.JLabel; javax.swing.JPanel; javax.swing.JSlider; javax.swing.event.ChangeEvent; javax.swing.event.ChangeListener; public class PanelConfiguracion extends JPanel implements Constantes{ public JLabel velocidad; public JSlider cambiarVelocidad; public PanelDeJuego panelJuego; public PanelConfiguracion(PanelDeJuego panelJuego) { this.panelJuego=panelJuego; //configuramos etiqueta velocidad=new JLabel("Cambiar Velocidad"); velocidad.setForeground(Color.yellow); velocidad.setFont(FUENTE); // cambiarVelocidad = new JSlider(JSlider.VERTICAL, VELOCIDAD_MINIMA, VELOCIDAD_MAXIMA,VELOCIDAD_INICIAL); //cambiarVelocidad.addChangeListener(this::escuchadorslider); cambiarVelocidad.addChangeListener(new ChangeListener() { @Override public void stateChanged(ChangeEvent e) { escuchadorslider(e); } }); cambiarVelocidad.setMajorTickSpacing(100); cambiarVelocidad.setPaintTicks(true); this.setBackground(Color.gray); this.setLayout(new BorderLayout()); add(velocidad,BorderLayout.WEST); add(cambiarVelocidad,BorderLayout.CENTER); } public void escuchadorslider(ChangeEvent e) { JSlider source = (JSlider)e.getSource(); if (!source.getValueIsAdjusting()) { System.out.println(" Velocidad "+source.getValue()); panelJuego.lienzo.jugador.tiempoDormido=source.getValue(); } } } Lienzo.java, ahora lanzamos las entidades en cada instante: lanzadorTareas=new Timer(); lanzadorTareas.scheduleAtFixedRate(adversario,0,1); lanzadorTareas.scheduleAtFixedRate(adversario2,0,1); lanzadorTareas.scheduleAtFixedRate(jugador,0,1); Jugador.java, ahora dormimos el jugador cada cierto tiempo @Override public void run() { if (direccion==0) moverJugadorDerecha(); else moverJugadorIzquierda(); try { Thread.sleep(tiempoDormido); } catch (InterruptedException ex) { } laberinto.lienzoPadre.repaint(); } Ahora jugador debe contar con un nuevo atributo int tiempoDormido, que indicara el tiempo en que el jugador se duerme. public class Jugador extends TimerTask implements Constantes{ public Laberinto laberinto; public Celda jugador; public int direccion;//para saber hacia donde me muevo public int tiempoDormido; public Jugador(Laberinto laberinto,int tiempoDormido) { this.tiempoDormido=tiempoDormido; [...] } } Notar que, al hacer Thread.sleep(tiempo), los Adversarios también se duermen ese tiempo. Para que cada elemento se duerma de forma independiente hay que transformar los TimerTask en Threads. Es decir, en lugar de heredar de TimerTask, heredar de Thread. Y en lugar de lanzarlos con un timer, lanzarlos con el método start(). Capítulo 5 Interacción de Entidades En este capitulo vamos a estudiar como se puede implementar la interacción entre las entidades. Interacción con entidades inmóviles (obstáculos) e interacción con entidades móviles (objetos que al chocar se muevan). La clase Jugador.java que daría como sigue: public class Jugador implements Constantes{ public Laberinto laberinto; public Celda celdaJugador; public Jugador(Laberinto laberinto) { this.laberinto=laberinto; celdaJugador=new Celda(0,0,’J’); laberinto.celdas[0][0].tipo=’J’; } public void moverJugadorArriba(){ if (celdaJugador.y > 0 ) { if ( laberinto.celdas[celdaJugador.x][celdaJugador.y-1].tipo != ’O’){ if ( laberinto.celdas[celdaJugador.x][celdaJugador.y-1].tipo == ’P’){ if ( celdaJugador.y-2 >=0 ) { laberinto.celdas[celdaJugador.x][celdaJugador.y-2].tipo=’P’; laberinto.celdas[celdaJugador.x][celdaJugador.y-1].tipo=’V’; } }else { laberinto.celdas[celdaJugador.x][celdaJugador.y].tipo=’V’; celdaJugador.y=celdaJugador.y-1; laberinto.celdas[celdaJugador.x][celdaJugador.y].tipo=’J’; } } } } public void moverJugadorAbajo(){ 51 if (celdaJugador.y+1 < alturaMundoVirtual ) { if ( laberinto.celdas[celdaJugador.x][celdaJugador.y+1].tipo != ’O’) { if ( laberinto.celdas[celdaJugador.x][celdaJugador.y+1].tipo == ’P’ ) { if ( celdaJugador.y+2 < alturaMundoVirtual ){ laberinto.celdas[celdaJugador.x][celdaJugador.y+2].tipo=’P’; laberinto.celdas[celdaJugador.x][celdaJugador.y+1].tipo=’V’; } }else { laberinto.celdas[celdaJugador.x][celdaJugador.y].tipo=’V’; celdaJugador.y=celdaJugador.y+1; laberinto.celdas[celdaJugador.x][celdaJugador.y].tipo=’J’; } } } } public void moverJugadorDerecha(){ if (celdaJugador.x < anchuraMundoVirtual-1 ) { if ( laberinto.celdas[celdaJugador.x+1][celdaJugador.y].tipo != ’O’) { if ( laberinto.celdas[celdaJugador.x+1][celdaJugador.y].tipo == ’P’ ) { if ( celdaJugador.x+2 < anchuraMundoVirtual-1){ laberinto.celdas[celdaJugador.x+2][celdaJugador.y].tipo=’P’; laberinto.celdas[celdaJugador.x+1][celdaJugador.y].tipo=’V’; } }else { laberinto.celdas[celdaJugador.x][celdaJugador.y].tipo=’V’; celdaJugador.x=celdaJugador.x+1; laberinto.celdas[celdaJugador.x][celdaJugador.y].tipo=’J’; } } } } public void moverJugadorIzquierda(){ if (celdaJugador.x > 0 ) { if ( laberinto.celdas[celdaJugador.x-1][celdaJugador.y].tipo != ’O’) { if ( laberinto.celdas[celdaJugador.x-1][celdaJugador.y].tipo == ’P’ ) { if ( celdaJugador.x-2 >=0){ laberinto.celdas[celdaJugador.x-2][celdaJugador.y].tipo=’P’; laberinto.celdas[celdaJugador.x-1][celdaJugador.y].tipo=’V’; } }else { laberinto.celdas[celdaJugador.x][celdaJugador.y].tipo=’V’; celdaJugador.x=celdaJugador.x-1; laberinto.celdas[celdaJugador.x][celdaJugador.y].tipo=’J’; } } } } } La clase quedaría como sigue: import java.awt.Graphics; import java.awt.event.KeyEvent; import javax.swing.JComponent; public class Laberinto extends JComponent implements Constantes { public public public public int anchuraLaberinto,alturaLaberinto;//dimensiones del laberinto Celda[][] celdas;//las casillas n x m Celda celdaMovimiento; Lienzo lienzoPadre; public Laberinto(Lienzo lienzoPadre) { this.lienzoPadre=lienzoPadre; celdas=new Celda[anchuraMundoVirtual][alturaMundoVirtual]; //inicializar el array de celdas for(int i=0; i < anchuraMundoVirtual; i++) for ( int j=0 ; j < alturaMundoVirtual ; j++) celdas[i][j]=new Celda(i+(i*anchuraCelda), j+(j*alturaCelda),’V’); celdaMovimiento=new Celda(0,0,’J’); celdas[celdaMovimiento.x][celdaMovimiento.y].tipo=’J’; //obstaculos celdas[5][5].tipo=’O’; celdas[5][7].tipo=’O’; celdas[6][3].tipo=’P’; //ancho y largo del laberinto this.anchuraLaberinto=anchuraMundoVirtual*anchuraCelda; this.alturaLaberinto=alturaMundoVirtual*alturaCelda; this.setSize(anchuraLaberinto,alturaLaberinto); } @Override public void update(Graphics g) { for(int i=0; i < anchuraMundoVirtual ; i++) for ( int j=0 ; j < alturaMundoVirtual; j++) celdas[i][j].update(g); } @Override public void paintComponent(Graphics g) { update(g); } public void moverCelda( KeyEvent evento ) { switch( evento.getKeyCode() ) { case KeyEvent.VK_UP: System.out.println("Mover arriba"); lienzoPadre.jugador.moverJugadorArriba(); break; case KeyEvent.VK_DOWN: System.out.println("Mover abajo"); lienzoPadre.jugador.moverJugadorAbajo(); break; case KeyEvent.VK_LEFT: System.out.println("Mover izquierda"); lienzoPadre.jugador.moverJugadorIzquierda(); break; case KeyEvent.VK_RIGHT: System.out.println("Mover derecha"); lienzoPadre.jugador.moverJugadorDerecha(); break; } } } La clase Lienzo.java quedaría como sigue: import import import import java.awt.Canvas; java.awt.Graphics; java.awt.Image; java.awt.event.KeyEvent; import import import import java.io.File; java.io.IOException; java.util.Timer; javax.imageio.ImageIO; public class Lienzo extends Canvas implements Constantes{ //para public public //para public public //Para public public public pintar el lienzo Laberinto laberinto; Image fondo; implementar el doble buffer Graphics graficoBuffer; Image imagenBuffer; animacion basica Adversario adversario,adversario2; Jugador jugador; Timer lanzadorTareas; public Lienzo() { laberinto=new Laberinto(this); adversario=new Adversario(laberinto); adversario2=new Adversario(laberinto); jugador=new Jugador(laberinto); try { fondo = ImageIO.read(new File("images/fondo.jpg")); } catch (IOException e) { System.out.println(e.toString()); } //dimensiones del lienzo this.setSize(laberinto.anchuraLaberinto,laberinto.alturaLaberinto); //escuchador eventos de teclado addKeyListener(new java.awt.event.KeyAdapter() { @Override public void keyPressed(KeyEvent evt) { laberinto.moverCelda(evt); repaint(); } }); lanzadorTareas=new Timer(); lanzadorTareas.scheduleAtFixedRate(adversario,0,1000); lanzadorTareas.scheduleAtFixedRate(adversario2,0,1000); } @Override public void update(Graphics g) { //inicializacion de buffer grafico if(graficoBuffer==null){ imagenBuffer=createImage(this.getWidth(),this.getHeight()); graficoBuffer=imagenBuffer.getGraphics(); } //volcamos contexto grafico actual graficoBuffer.setColor(getBackground()); graficoBuffer.fillRect(0,0,this.getWidth(),this.getHeight()); graficoBuffer.drawImage(fondo, 0, 0, null); laberinto.update(graficoBuffer); //pintamos la imagen del previa g.drawImage(imagenBuffer, 0, 0, null); } //metodo llamada la primera vez que se pinta @Override public void paint(Graphics g) { update(g); } } La clase Celda.java quedaría como sigue: import java.awt.Graphics; import java.awt.Point; import java.awt.Rectangle; import import import import import java.io.File; java.io.IOException; java.awt.image.BufferedImage; javax.imageio.ImageIO; javax.swing.JComponent; public class Celda extends JComponent implements Constantes { public public public public public int x; int y; char tipo; BufferedImage jugador,obstaculo,camino, adversario; BufferedImage pelota; //constructor public Celda(int x,int y,char tipo) { this.x=x; this.y=y; this.tipo=tipo; try { jugador = ImageIO.read(new File("images/jugador.png")); obstaculo = ImageIO.read(new File("images/obstaculo.png")); adversario = ImageIO.read(new File("images/adversario.png")); pelota=ImageIO.read(new File("images/pelota.png")); } catch (IOException e) { System.out.println(e.toString()); } } //metodo llamado cuando repaint @Override public void update(Graphics g) { switch(tipo) { case ’J’: g.drawImage(jugador,x,y, null); break; case ’O’: g.drawImage(obstaculo,x,y, this); break; case ’V’: g.setColor(COLORFONDO); g.fillRect(x, y,anchuraCelda,alturaCelda); break; case ’A’: g.drawImage(adversario,x,y, this); break; case ’P’: g.drawImage(pelota,x,y, this); break; } } //metodo para dibujar una casilla @Override public void paintComponent(Graphics g) { update(g); } //si el click esta sobre la celda public boolean celdaSeleccionada(int xp,int yp) { Rectangle r=new Rectangle(x,y,anchuraCelda,alturaCelda); return r.contains(new Point(xp,yp)); } } Capítulo 6 Técnicas de Inteligencia Artificial: Búsqueda 6.1. 6.1.1. Búsqueda sin información del dominio. Contexto previo: ¿Por qué estudiar búsqueda? Gran parte de los primeros trabajos desarrollados en el campo de la Inteligencia Artificial (décadas de los cincuenta y sesenta) abordaban problemas que eran idealizaciones o simplificaciones muy fuertes del mundo real: demostración automática de teoremas, planificación en el mundo de los bloques, problemas de juegos, etc. La metodología propia de esta época consistía en la realización de procesos de búsqueda en espacios de estados, dando énfasis al empleo de conocimiento específico sobre el dominio. Las estrategias de control que guían la búsqueda marcaron uno de los principales temas de interés e este periodo y todavía hoy constituyen una parte importante de la inteligencia artificial aplicada. 6.1.2. ¿Qué conocimientos previos se suponen necesarios para comprender este capítulo? Conceptos básicos sobre árboles y grafos. Nociones elementales de uso (no de implementación) de estructura de datos como pilas, colas, listas o tablas hash. Conocimientos sobre el cálculo de la complejidad de un algoritmo. Nociones sobre conceptos propios de programación tales como recursividad e el empleo de bucles. 6.2. 6.2.1. Contenido teórico Introducción Las técnicas de búsqueda tienen sentido aplicarlas en problemas que reúnen una seria de características: 59 Figura 6.1: Técnica de Búsqueda 1. Existe la posibilidad de asociar un conjunto de estados a las diferentes situaciones en que se puede encontrar el objeto del dominio sobre el que se define el problema. 2. Hay una serie de estados iniciales desde los que se empezaría el proceso de búsqueda. 3. Existen ciertos operadores, tal que un operador aplicado sobre un estado producirá otro estado. 4. Existe al menos un estado meta o estado solución. Cualquier proceso de búsqueda persigue, asociando nodos con estados y arcos con operadores, encontrar un camino que con que conduzca de un nodo inicial a otro meta. Se define el espacio de estados como el conjunto de los mismos que podrían obtenerse si se aplicaran todos los operadores posibles a todos los estados que se fueran generando. La búsqueda sin información del dominio pretende realizar una exploración exhaustiva del espacio de estados -dado que no hay conocimiento que pueda guiar la misma- que, en principio, no deje ningún nodo sin ser examinado. Existen diversas formas de llevar a cabo el proceso anterior, la diferencia reside en el orden de generar los diferentes estados a partir del estado final. 6.3. 6.3.1. Tipos de búsqueda Amplitud La búsqueda en amplitud consiste en usar una cola para almacenar los estados que se van generando. Este tipo de exploración recibe el nombre de búsqueda en amplitud y garantiza la obtención de la solución de menor coste (óptima), si es que ésta existe. 6.3.2. Implementación en nuestro proyecto En nuestro proyecto por ahora el jugador se mueve mediante el teclado. La implementación del algoritmo de búsqueda en amplitud nos permitirá que el jugador se mueva de forma autónoma. Evidentemente debemos indicar al algoritmo dónde está situado el jugador y a donde queremos que llegue. Por ejemplo, el jugador lo situamos en la posición (0,0) y queremos que vaya a la celda donde la pelota está situada. Antes de nada haremos un cambio, situaremos la pelota en la esquina final de abajo. Debemos ir a la clase Laberinto.java y y modificar la instrucción de la pelota: celdas[anchuraMundoVirtual-1][alturaMundoVirtual-1].tipo=’P’; nos debería quedar algo similar a lo siguiente: public Laberinto(Lienzo lienzoPadre) { this.lienzoPadre=lienzoPadre; celdas=new Celda[anchuraMundoVirtual][alturaMundoVirtual]; //inicializar el array de celdas for(int i=0; i < anchuraMundoVirtual; i++) for ( int j=0 ; j < alturaMundoVirtual ; j++) celdas[i][j]=new Celda(i+(i*anchuraCelda), j+(j*alturaCelda),’V’); celdaMovimiento=new Celda(0,0,’J’); celdas[celdaMovimiento.x][celdaMovimiento.y].tipo=’J’; //obstaculos celdas[5][5].tipo=’O’; celdas[5][7].tipo=’O’; celdas[anchuraMundoVirtual-1][alturaMundoVirtual-1].tipo=’P’; //ancho y largo del laberinto this.anchuraLaberinto=anchuraMundoVirtual*anchuraCelda; this.alturaLaberinto=alturaMundoVirtual*alturaCelda; this.setSize(anchuraLaberinto,alturaLaberinto); } La técnica de búsqueda en un espacio de estado requiere emplear una estructura de datos Estado que guarda información sobre el estado del problema en un instante dado. Por ejemplo, en nuestro caso, nuestra posición. Además el estado cuenta con un atributo char oper que guarda información de la operación que se realizó para llegar a ese estado y una referencia Estado predecesor que apunta al estado a partir del cual se generó dicho estado. Una posible implementación de esta clase sería la siguiente: public class Estado { //posicion x e y de la entidad public int x; public int y; public char oper; public Estado predecesor; public Estado(int x, int y, char oper,Estado predecesor) { this.x=x; this.y=y; this.oper=oper; this.predecesor=predecesor; } @Override public boolean equals(Object x) { Estado e=(Estado)x; return this.x==e.x && this.y==e.y; } @Override public int hashCode() { int hash = 3; hash = 89 * hash + this.x; hash = 89 * hash + this.y; return hash; } @Override public String toString() { return "("+x+","+y+")"; } } La clase BusquedaAnchura está formada por las estructuras de datos necesarias para implementar el algoritmo de búsqueda en anchura. Recibe laberinto para conocer la situación de cada elemento. Una cola estados donde se irán almacenando los estados que están siendo explorados. Un historial para saber que estados fueron ya explorados. Los pasos, que será la salida del algoritmo, es decir, qué pasos he de dar para ir del estado inicial al meta/objetivo. public class BusquedaAnchura extends TimerTask implements Constantes{ public public public public public public public public public Laberinto laberinto; ArrayList<Estado> colaEstados; ArrayList<Estado> historial; ArrayList<Character> pasos; int index_pasos; Estado inicial; Estado objetivo; Estado temp; boolean exito; public BusquedaAnchura(Laberinto laberinto) { this.laberinto=laberinto; colaEstados=new ArrayList<>(); historial=new ArrayList<>(); pasos=new ArrayList<>(); index_pasos=0; exito=false; } El método buscar es muy simple y está formado por unas pocas lineas de código: public void buscar(int x1,int y1,int x2,int y2) { //creamos el estado inicial y el objetivo inicial=new Estado(x1,y1,’N’,null); objetivo=new Estado(x2,y2,’P’,null); //los añadimos a la cola de estados y al historial colaEstados.add(inicial); historial.add(inicial); //si el inicial es final, salimos if ( inicial.equals(objetivo)) exito=true; // si no mientras que la cola no este vacia y no hayamos // alcanzado el meta hacemos lo siguiente while ( !colaEstados.isEmpty() && !exito ){ //tomamos el primero y lo quitamos de cola de estad temp=colaEstados.get(0); colaEstados.remove(0); //lo exploramos, es decir, generamos sus sucesores, // es decir, los estados a los que podemos ir desde el // estado actual moverArriba(temp); moverAbajo(temp); moverIzquierda(temp); moverDerecha(temp); } if ( exito ) System.out.println("Ruta calculada"); else System.out.println("La ruta no pudo calcularse"); } Por último implementamos los métodos de expansión que consiste en generar los estados sucesores: private void moverArriba(Estado e) { if ( e.y > 0 ) { if ( laberinto.celdas[e.x][e.y-1].tipo != ’O’ ) { Estado arriba=new Estado(e.x,e.y-1,’U’,e); if ( !historial.contains(arriba)) { colaEstados.add(arriba); historial.add(arriba); if ( arriba.equals(objetivo)) { objetivo=arriba; exito=true; } } } } }//fin del metodo moverArriba private void moverAbajo(Estado e) { if ( e.y+1 < alturaMundoVirtual ) { if ( laberinto.celdas[e.x][e.y+1].tipo != ’O’ ) { Estado abajo=new Estado(e.x,e.y+1,’D’,e); if ( !historial.contains(abajo)) { colaEstados.add(abajo); historial.add(abajo); if ( abajo.equals(objetivo)) { objetivo=abajo; exito=true; } } } } } private void moverIzquierda(Estado e) { if ( e.x > 0 ) { if ( laberinto.celdas[e.x-1][e.y].tipo != ’O’ ) { Estado izquierda=new Estado(e.x-1,e.y,’L’,e); if ( !historial.contains(izquierda)) { colaEstados.add(izquierda); historial.add(izquierda); if ( izquierda.equals(objetivo)) { objetivo=izquierda; exito=true; } } } } }// fin del metodo izquierda private void moverDerecha(Estado e) { if ( e.x < anchuraMundoVirtual-1 ) { if ( laberinto.celdas[e.x+1][e.y].tipo != ’O’ ) { Estado derecha=new Estado(e.x+1,e.y,’R’,e); if ( !historial.contains(derecha)){ colaEstados.add(derecha); historial.add(derecha); if ( derecha.equals(objetivo)) { objetivo=derecha; exito=true; } } } } } Una vez construido el corazón del algoritmo de búsqueda, si hubo exito entonces podemos reconstruir la soluciòn: public void calcularRuta() { Estado predecesor=objetivo; do{ pasos.add(predecesor.oper); predecesor=predecesor.predecesor; }while ( predecesor != null); index_pasos=pasos.size()-1; } En pasos están almacenados las acciones a realizar, por tanto el método run() tendrá la siguiente implementación: @Override public synchronized void run() { if ( index_pasos >= 0 ) { switch(pasos.get(index_pasos)) { case case case case ’D’: ’U’: ’R’: ’L’: laberinto.lienzoPadre.jugador.moverJugadorAbajo();break; laberinto.lienzoPadre.jugador.moverJugadorArriba(); break; laberinto.lienzoPadre.jugador.moverJugadorDerecha();break; laberinto.lienzoPadre.jugador.moverJugadorIzquierda();break; } laberinto.lienzoPadre.repaint(); index_pasos--; }else { this.cancel(); } } A continuación debemos extender la clase Jugador, ya que esté tendrá asociado una inteligencia: public class Jugador implements Constantes{ public Laberinto laberinto; public Celda celdaJugador; public BusquedaAnchura inteligencia; <----------------------- public Jugador(Laberinto laberinto) { this.laberinto=laberinto; celdaJugador=new Celda(0,0,’J’); laberinto.celdas[0][0].tipo=’J’; inteligencia=new BusquedaAnchura(laberinto); <-----------} [...] } Por último debemos lanzar el jugador en lienzo: laberinto.lienzoPadre.jugador.inteligencia.buscar(0,0, anchuraMundoVirtual-1,alturaMundoVirtual-1); laberinto.lienzoPadre.jugador.inteligencia.calcularRuta(); lanzadorTareas=new Timer(); lanzadorTareas.scheduleAtFixedRate(jugador.inteligencia,0,1000); //lanzadorTareas.scheduleAtFixedRate(adversario,0,1000); //lanzadorTareas.scheduleAtFixedRate(adversario2,0,1000); 6.3.3. Búsqueda Anchura Multiobjetivo La solución anterior nos permite ir de una posición inicial a una posición final. Pero no es efectiva si se produce algún cambio en la ruta. Por ejemplo, poner un obstáculo. Piense, por ejemplo, en un sistema software que nos proporcione rutas en una determinada zona geográfica. Si el día que consultamos la ruta es lluvioso es posible que una determinada ruta no se pueda aconsejar. Por tanto se debe crear una solución que tenga en cuenta posible cambios en la ruta en cada paso. Para ello debemos añadir tres atributos a la clase BusquedaAnchura.java. Una lista de destinos que nos proporcione una secuencia de estados que queremos visitar, una variable Jugador que que nos permita saber el jugador al que pertenece el algoritmo de búsqueda y una variable booleana parar para saber cuándo hemos terminado de recorrer todos los destinos. El constructor de la clase se modifica para pasar el jugador como argumento. import java.util.ArrayList; import java.util.TimerTask; public class BusquedaAnchura extends TimerTask implements Constantes{ //para tener un busqueda anchura multiobjetivo public Jugador jugador; public ArrayList<Estado> destinos; public boolean parar; //el resto de atributos quedarian igual [...] public BusquedaAnchura(Laberinto laberinto,Jugador jugador) { [...] this.jugador=jugador; destinos=new ArrayList<>(); parar=false; } Posteriormente debemos modificar el método buscar, ahora devolverá un booleano y recibirá como parámetros un estado inicial y un estado objetivo. public boolean buscar(Estado inicial,Estado objetivo) { index_pasos=0; colaEstados.add(inicial); historial.add(inicial); this.objetivo=objetivo; exito=false; if ( inicial.equals(objetivo)) exito=true; while ( !colaEstados.isEmpty() && !exito ){ temp=colaEstados.get(0); colaEstados.remove(0); moverArriba(temp); moverAbajo(temp); moverIzquierda(temp); moverDerecha(temp); } if ( exito ) { System.out.println("Ruta calculada"); this.calcularRuta(); return true; } else { System.out.println("La ruta no pudo calcularse"); return false; } } Los métodos de mover quedan sin modificación. El método calcular ruta sufre una pequeña modificación public void calcularRuta() { Estado predecesor=objetivo; do{ pasos.add(0,predecesor.oper);<-----------------predecesor=predecesor.predecesor; }while ( predecesor != null); index_pasos=pasos.size()-1; } El método que más cambios va a sufrir es el método run() ya que será lazando en cada instante de tiempo por el lanzador de tareas. Por tanto, en cada paso debemos reiniciar las estructuras de datos empleadas para la búsqueda y comprar si hemos alcanzado el destino. Si alcanzamos el destino, lo eliminamos de la lista y seguimos con el siguiente. Adicionalmente debemos comprobar que la lista de destino no es vacía, en caso contrario debemos parar el algoritmo, poner el atributo parar igual a true y cancelar el hilo de ejecución. @Override public void run() { //solo cuando quedan destinos donde ir if ( ! parar ) { //inicializacion de la busqueda colaEstados.clear(); historial.clear(); pasos.clear(); Estado subinicial,subobjetivo; boolean resultado; do{ //el estado inicial es donde estoy subinicial=new Estado(jugador.celdaJugador.x, jugador.celdaJugador.y,’N’,null); //el estado final es a donde quiero ir subobjetivo=destinos.get(0); //busco ruta resultado=this.buscar(subinicial,subobjetivo); if ( subinicial.equals(subobjetivo)) destinos.remove(subobjetivo); if ( destinos.isEmpty() ) { System.out.println("Se acabo a donde ir"); this.cancel(); } }while(!resultado && !destinos.isEmpty()); if ( pasos.size() > 1 ) { switch(pasos.get(1)) { case ’D’: jugador.moverJugadorAbajo();break; case ’U’: jugador.moverJugadorArriba(); break; case ’R’: jugador.moverJugadorDerecha();break; case ’L’: jugador.moverJugadorIzquierda();break; } laberinto.lienzoPadre.repaint(); } } } La clase Jugador.java sufre una ligera modificación: public Jugador(Laberinto laberinto) { this.laberinto=laberinto; celdaJugador=new Celda(0,0,’J’); laberinto.celdas[0][0].tipo=’J’; inteligencia=new BusquedaAnchura(laberinto,this); } Por último debemos lanzar al jugador en la clase Lienzo, note que el código de la versión anterior donde llamábamos al método buscar desparece y sólo se incluye lo necesario para lanzar la inteligencia del jugador (solo indicamos la parte que sufre modificación) public Lienzo() { [...] jugador.inteligencia.destinos.add(new Estado(5,5,’N’,null)); jugador.inteligencia.destinos.add(new Estado(14,4,’N’,null)); lanzadorTareas=new Timer(); lanzadorTareas.scheduleAtFixedRate(jugador.inteligencia,0,500); //lanzadorTareas.scheduleAtFixedRate(adversario,0,1000); //lanzadorTareas.scheduleAtFixedRate(adversario2,0,1000); } 6.3.4. Tarea voluntaria para tener un 4 en el certamen de laboratorio al primer alumno que la realice Notar que cuando: celdas[5][5].tipo=’O’; celdas[5][7].tipo=’O’; Y la búsqueda se realiza con estos destinos: jugador.inteligencia.destinos.add(new Estado(5,5,’N’,null)); jugador.inteligencia.destinos.add(new Estado(14,4,’N’,null)); El jugador no se mueve, no se encuentra ruta al primer subobjetivo. El algoritmo debería ir al siguiente destino ante la imposibilidad de ir al primero. Además en caso de que los destinos estén correctamente se debería comportar de forma adecuada. Sobre el código presentado en esta sección tratar de solucionar este problema. Una de las soluciones para este problema consiste en añadir un nuevo caso en el condicional del método run(). if ( subinicial.equals(subobjetivo) ) destinos.remove(subobjetivo); else { if ( !resultado) { colaEstados.clear(); historial.clear(); pasos.clear(); destinos.remove(subobjetivo); } } 6.3.5. Dándole inteligencia a los Adversarios Para darle inteligencia a los adversarios crearemos una BusquedaProfundidad.java que será análoga a la clase BusquedaAnchura.java con la diferencia del método run() @Override public void run() { if ( ! parar ) { colaEstados.clear(); historial.clear(); pasos.clear(); Estado subinicial,subobjetivo; boolean resultado; subinicial=new Estado(adversario.celdaAdversario.x, adversario.celdaAdversario.y,’N’,null); subobjetivo=new Estado( adversario.laberinto.lienzoPadre.jugador.celdaJugador.x, adversario.laberinto.lienzoPadre.jugador.celdaJugador.y,’N’,null); resultado=this.buscar(subinicial,subobjetivo); if ( pasos.size() > 1 ) { switch(pasos.get(1)) { case ’D’: adversario.moverJugadorAbajo();break; case ’U’: adversario.moverJugadorArriba(); break; case ’R’: adversario.moverJugadorDerecha();break; case ’L’: adversario.moverJugadorIzquierda();break; } laberinto.lienzoPadre.repaint(); } } } El siguiente paso es modificar la clase Adversario.java, ésta deja de ser un timertask, ahora tendrá un atributo BusquedaProfundidad. Notar que en lugar de ’J’ se debe poner ’A’ cuando se produce un movimiento. Sólo indicamos un movimiento, el resto serían de la misma forma. public class Adversario implements Constantes{ public Laberinto laberinto; public Celda celdaAdversario; public BusquedaProfundidad inteligencia; public Adversario(Laberinto laberinto) { this.laberinto=laberinto; celdaAdversario=new Celda(anchuraMundoVirtual-1,alturaMundoVirtual-1,’A’); laberinto.celdas[celdaAdversario.x][celdaAdversario.y].tipo=’A’; inteligencia=new BusquedaProfundidad(laberinto,this); } public void moverJugadorArriba(){ if (celdaAdversario.y > 0 ) { if ( laberinto.celdas[celdaAdversario.x][celdaAdversario.y-1].tipo != ’O’) { if ( laberinto.celdas[celdaAdversario.x][celdaAdversario.y-1].tipo==’P’) { if ( celdaAdversario.y-2 >=0 ) { laberinto.celdas[celdaAdversario.x][celdaAdversario.y-2].tipo=’P’; laberinto.celdas[celdaAdversario.x][celdaAdversario.y-1].tipo=’V’; } }else { laberinto.celdas[celdaAdversario.x][celdaAdversario.y].tipo=’V’; celdaAdversario.y=celdaAdversario.y-1; laberinto.celdas[celdaAdversario.x][celdaAdversario.y].tipo=’A’; } } } } [...] //resto de movimientos } Por último en Lienzo.java quitamos el segundo atributo Adversario y nos quedamos con uno. public class Lienzo extends Canvas implements Constantes{ [...] public Adversario adversario; [...] public Lienzo() { [...] adversario=new Adversario(laberinto); [...] jugador.inteligencia.destinos.add(new jugador.inteligencia.destinos.add(new jugador.inteligencia.destinos.add(new jugador.inteligencia.destinos.add(new jugador.inteligencia.destinos.add(new Estado(5,5,’N’,null)); Estado(14,4,’N’,null)); Estado(5,5,’N’,null)); Estado(0,0,’N’,null)); Estado(5,5,’N’,null)); lanzadorTareas=new Timer(); lanzadorTareas.scheduleAtFixedRate(jugador.inteligencia,0,500); lanzadorTareas.scheduleAtFixedRate(adversario.inteligencia,0,500); } [...] } Capítulo 7 Búsqueda Informada Queremos emplear un lista de estados ordenadas por algún valor. Aquí consideramos que un estado es mejor si pasa cerca del adversario. Hay que emplear la estructura de datos PriorityQueue. Para poder utilizarla todo elemento que esté en esta estructura deberá implementar de la interfaz Comparable. Esto es debido a que la comparación de objetos en las colas de prioridad en Java se hacen a través del método compareTo(). 7.0.6. Cambios la clase Estado class Estado implements Comparable{ public int x; public int y; //’N’=nada, ’L’: izquierda, ’R’: derecha, ’U’: Arriba, ’D’: abajo public char oper; public Estado predecesor; public double prioridad; public Estado(int x, int y, char oper,Estado predecesor) { this.x=x; this.y=y; this.oper=oper; this.predecesor=predecesor; } @Override public boolean equals(Object x) { Estado e=(Estado)x; return this.x==e.x && this.y==e.y; } @Override public String toString() { return "("+x+","+y+"): Prioridad= "+this.prioridad; 75 } @Override public int compareTo(Object o) { Estado e=(Estado)o; if ( this.prioridad == e.prioridad ) return 0; else { if ( this.prioridad > e.prioridad ) return 1; else return -1; } } } 7.0.7. Cambios en la clase Busqueda Anchura Además de modificar la estructura colaEstados, notar que cada vez que se genera un estado se calcula la prioridad del mismo. import import import import java.util.ArrayList; java.util.PriorityQueue; java.util.Queue; java.util.TimerTask; public class BusquedaAnchura extends TimerTask implements Constantes{ public public public public public public public public public Laberinto laberinto; Queue<Estado> colaEstados; ArrayList<Estado> historial; ArrayList<Character> pasos; int index_pasos; Estado inicial; Estado objetivo; Estado temp; boolean exito; public BusquedaAnchura(Laberinto laberinto) { this.laberinto=laberinto; colaEstados=new PriorityQueue<>(); historial=new ArrayList<>(); pasos=new ArrayList<>(); index_pasos=0; exito=false; } public void buscar(int x1,int y1,int x2,int y2) { inicial=new Estado(x1,y1,’N’,null); inicial.prioridad=distancia(x1,y1,laberinto.lienzoPadre.adversario.adversar laberinto.lienzoPadre.adversario.adversario.y); objetivo=new Estado(x2,y2,’P’,null); colaEstados.add(inicial); historial.add(inicial); if ( inicial.equals(objetivo)) exito=true; while ( !colaEstados.isEmpty() && !exito ){ temp=colaEstados.poll(); moverArriba(temp); moverAbajo(temp); moverIzquierda(temp); moverDerecha(temp); } if ( exito ) System.out.println("Ruta calculada"); else System.out.println("La ruta no pudo calcularse"); } //distancia adversario public double distancia(int x1,int y1, int x2, int y2) { double valor; double parte1=Math.pow(Math.abs(x1-x2),2); double parte2=Math.pow(Math.abs(y1-y2),2); parte1+=parte2; valor=Math.sqrt(parte1); return valor; } private void moverArriba(Estado e) { if ( e.y > 0 ) { if ( laberinto.celdas[e.x][e.y-1].tipo != ’O’ ) { Estado arriba=new Estado(e.x,e.y-1,’U’,e); arriba.prioridad=distancia(arriba.x,arriba.y, laberinto.lienzoPadre.adversario.adversario.x, laberinto.lienzoPadre.adversario.adversario.y); if ( !historial.contains(arriba)) { colaEstados.add(arriba); historial.add(arriba); if ( arriba.equals(objetivo)) { objetivo=arriba; exito=true; } } } } } private void moverAbajo(Estado e) { if ( e.y+1 < alturaMundoVirtual ) { if ( laberinto.celdas[e.x][e.y+1].tipo != ’O’ ) { Estado abajo=new Estado(e.x,e.y+1,’D’,e); abajo.prioridad=distancia(abajo.x,abajo.y, laberinto.lienzoPadre.adversario.adversario.x, laberinto.lienzoPadre.adversario.adversario.y); if ( !historial.contains(abajo)) { colaEstados.add(abajo); historial.add(abajo); //laberinto.celdas[e.x][e.y+1].tipo=’A’; if ( abajo.equals(objetivo)) { //laberinto.celdas[e.x][e.y+1].tipo=’P’; objetivo=abajo; exito=true; } } } } } private void moverIzquierda(Estado e) { if ( e.x > 0 ) { if ( laberinto.celdas[e.x-1][e.y].tipo != ’O’ ) { Estado izquierda=new Estado(e.x-1,e.y,’L’,e); izquierda.prioridad=distancia(izquierda.x,izquierda.y, laberinto.lienzoPadre.adversario.adversario.x, laberinto.lienzoPadre.adversario.adversario.y); if ( !historial.contains(izquierda)) { colaEstados.add(izquierda); historial.add(izquierda); if ( izquierda.equals(objetivo)) { objetivo=izquierda; exito=true; } } } } } private void moverDerecha(Estado e) { if ( e.x < anchuraMundoVirtual-1 ) { if ( laberinto.celdas[e.x+1][e.y].tipo != ’O’ ) { Estado derecha=new Estado(e.x+1,e.y,’R’,e); derecha.prioridad=distancia(derecha.x,derecha.y, laberinto.lienzoPadre.adversario.adversario.x, laberinto.lienzoPadre.adversario.adversario.y); if ( !historial.contains(derecha)){ colaEstados.add(derecha); historial.add(derecha); if ( derecha.equals(objetivo)) { objetivo=derecha; exito=true; } } } } } public void calcularRuta() { Estado predecesor=objetivo; do{ pasos.add(predecesor.oper); predecesor=predecesor.predecesor; }while ( predecesor != null); index_pasos=pasos.size()-1; } @Override public synchronized void run() { if ( index_pasos >= 0 ) { switch(pasos.get(index_pasos)) { case ’D’: laberinto.lienzoPadre.jugador.moverJugadorAbajo(); break; case ’U’: laberinto.lienzoPadre.jugador.moverJugadorArriba(); break; case ’R’: laberinto.lienzoPadre.jugador.moverJugadorDerecha(); break; case ’L’: laberinto.lienzoPadre.jugador.moverJugadorIzquierda(); break; } laberinto.lienzoPadre.repaint(); index_pasos--; }else { this.cancel(); } } }