Quantcast
Channel: vidasConcurrentes » java
Viewing all articles
Browse latest Browse all 10

Creando una aplicación de Android: el juego y la lógica (parte 2)

$
0
0

Bienvenidos a la cuarta parte de la serie de entradas en el ciclo Creando una aplicación de Android. Si aún no lo has hecho, comienza desde la primera entrada mostrada en el menú inmediatamente superior.

Esta entrada se basa en el resultado de completar la anterior parte, en la que construíamos una nueva Activity para ejecutar nuestro juego. Además creábamos las clases necesarias para el pintado del juego y las necesarias para representar los elementos del juego. El resultado al que habíamos llegado era mostrar lo siguiente:

En esta entrada vamos a completar la lógica del juego, haciendo que las raquetas y la bola se muevan y, en definitiva, se pueda jugar.

¡Comenzamos!

Importante: esta entrada va a basarse en los códigos creados en las anteriores entradas del ciclo al que pertenece. Tienes disponible pinchando aquí (MD5: e50037a2c1219ea0b5e8e464c72390aa) el proyecto de Eclipse con todo lo hecho hasta ahora, el cual puedes importar a tu Eclipse y comenzar a trabajar.

Nota: esta entrada es bastante larga, pero incluye mucho código. ¡Quedáis avisados!

Recordemos que en la anterior entrada creamos una serie de clases que nos iban a servir para representar nuestros elementos del juego. Disponíamos de una superclase ElementoPong que describía la representación de un elemento del juego (un rectángulo definido por una coordenada origen, un ancho y un alto), una interfaz ElementoPongMovil que obliga a implementar el método move() y dos clases que heredaban de la primera e implementaban la segunda (Raqueta y Bola).

Vamos a comenzar con el movimiento de las palas. Queremos que se muevan al tocar con el dedo sobre ellas, y se muevan donde vaya nuestro dedo.
Para hacer esto, vamos a sobrescribir el método onTouchEvent() en nuestro PongGameView:

@Override
public boolean onTouchEvent(MotionEvent event) {
 int x = (int) event.getX();
 int y = (int) event.getY();

 return true;
}

De esta forma estamos detectando las coordenadas X e Y en las que se ha detectado el evento, aunque de momento no hacemos nada con ellas. El siguiente paso es detectar qué tipo de acción se ha hecho. ¿Hemos pulsado, hemos arrastrado o hemos levantado el dedo? Son los tres eventos que vamos a detectar. Para ello añadimos lo siguiente:

@Override
public boolean onTouchEvent(MotionEvent event) {
 int x = (int) event.getX();
 int y = (int) event.getY();

 switch(event.getAction()) {
 case MotionEvent.ACTION_DOWN:
  // hemos pulsado
  break;
 case MotionEvent.ACTION_MOVE:
  // hemos arrastrado
  break;
 case MotionEvent.ACTION_UP:
  // hemos levantado
  break;
 }

 return true;
}

El evento ACTION_DOWN se produce en el momento en que tocamos con el dedo en pantalla. El evento ACTION_MOVE se produce cuando, después de un ACTION_DOWN, movemos el dedo sin levantarlo. Finalmente, el evento ACTION_UP se produce al levantar el dedo de la pantalla.

En nuestro Pong vamos a querer poder mover la raqueta izquierda y la raqueta derecha, pero la bola deberá moverse sola. Por tanto, en nuestro ACTION_DOWN vamos a ver si hemos tocado en la raqueta izquierda, la derecha o en ninguna de ellas (siendo el estado inicial ninguna). Si hemos tocado en una de las dos, tenemos que quedarnos con cual es la que hemos tocado para poder aplicarle los movimientos del ACTION_MOVE. Una vez que hagamos ACTION_UP lo que haremos será decir ya no estoy tocando ninguna de las raquetas.

Formas de hacer esto hay muchas y muy buenas, como por ejemplo usar un identificador para cada elemento, tener una colección de elementos móviles y pulsables y luego iterar sobre ella para encontrar el elemento sobre el que se pulsó y quedarse con su identificador… Sin embargo nosotros vamos a hacerlo simple, puesto que sólo vamos a permitir tocar en dos figuras (y eso sólo cuando jueguen dos personas). Además, para calcular un desplazamiento, necesitamos la posición que vamos a llamar origen y la actual.

Lo primero, entonces, será añadir un nuevo parámetro al PongGameView, que será el elemento activo, y el origen del movimiento, que será la posición del eje Y donde hemos puesto el dedo:

private ElementoPong elementoActivo = null;
private int origenY;

Se inicializa a null porque inicialmente no hay ningún elemento activo.
Ahora vamos a modificar el caso del ACTION_DOWN de la siguiente forma:

case MotionEvent.ACTION_DOWN:
 // hemos pulsado
 if(raquetaIzda.getRectElemento().contains(x, y)) {
  elementoActivo = raquetaIzda;
  origenY = y;
  break;
 }
 if(raquetaDcha.getRectElemento().contains(x, y)) {
  elementoActivo = raquetaDcha;
  origenY = y;
  break;
 }
 break;

Lo que hacemos es asignar al elemento activo el elemento sobre el que hemos tocado (si es que hemos tocado en alguno), y además asignamos el la posición Y en la que hemos pulsado a nuestra variable origenY. Repetimos el código de la asignación en lugar de hacerlo siempre para evitar asignaciones innecesarias (los dispositivos son suficientemente rápidos como para procesar unas miles de asignaciones extra… pero es interesante valorar el lugar donde vamos a poner nuestras instrucciones con respecto del número de veces que se ejecutarán). El método contains(int,int) de la clase Rect nos viene de perlas para no tener que hacer una línea con 4 and. Aunque nunca en nuestro juego puede darse que estemos tocando la raqueta izquierda y además la raqueta derecha, hacemos un break después de asignar el elemento para curarnos en salud, y de paso ejecutar un par de operaciones menos.

Lo siguiente que vamos a hacer es el caso del ACTION_UP, que es tan simple como volver a null el elemento activo. La variable origenY no la vamos a devolver a ningún estado nulo porque no nos hace falta (no hacemos comprobaciones con ella). Podríamos hacer una comprobación para poner null si el elemento activo no lo es ya, pero como en nuestro caso queremos que no haya nada activo al soltar, lo hacemos de la siguiente forma:

case MotionEvent.ACTION_UP:
 // hemos levantado
 elementoActivo = null;
 break;

Finalmente, el caso de arrastrar. El concepto es: si hemos pulsado en un elemento durante el ACTION_DOWN, entonces queremos moverle. Sin embargo, no queremos que nuestras raquetas se muevan horizontalmente, sino sólo verticalmente. Por tanto la modificación de la coordenada será sólo en la Y. Recordemos que en los gráficos de Android (y en Java en general), se considera el origen de coordenadas la esquina superior izquierda, y no la inferior izquierda como en un sistema de coordenadas cartesianas. Por tanto, si vamos hacia abajo sumamos y si vamos hacia arriba restamos.

Es, por tanto, el momento de implementar el método move() para las raquetas. Por ahora, vamos a tomar la decisión de diseño de cambiar el método move() de la interfaz ElementoPongMovil, para hacerla de la siguiente forma:

package com.vidasconcurrentes.pongvc.juego;

public interface ElementoPongMovil {
 public void move(int x, int y);
}

Ahora al método move() le pasamos dos números enteros, que son el número de píxeles que debe moverse en el eje X y el Y.

El método move() de la Raqueta y la Bola va a necesitar modificar su origen de coordenadas, por lo que hemos de añadir los siguientes métodos a ElementoPong:

public void setOrigenX(int newX) {
 origen.setX(newX);
}

public void setOrigenY(int newY) {
 origen.setY(newY);
}

La clase Coordenada ya tenía la funcionalidad para modificar sus valores.
Ahora sí, el método move() para las Raquetas:

@Override
public void move(int x, int y) {
 origen.setY(origen.getY() + y);
}

Simple. Recordemos que no nos interesa ningún desplazamiento en el eje X, así que nos da igual el valor de X que venga.

Volviendo al ACTION_MOVE, éste es su código:

case MotionEvent.ACTION_MOVE:
 // hemos arrastrado
 if(elementoActivo != null) {
  Raqueta r = (Raqueta) elementoActivo;
  r.move(0, y - origenY);
 }
 origenY = y;
 break;

Sólo calculamos la diferencia de posiciones para mandárselo al método move(). Necesitamos hacer el casting explícito a Raqueta porque si no, no disponemos de move(). A move() le pasamos como primer parámetro un 0 que, aunque nos da igual su valor, nos ayuda a ver que no nos moveremos en el eje X sin tener que saber el código de move().

Bien, ahora podemos mover las raquetas por pantalla y sólo verticalmente pero… echemos un ojo a la siguiente imagen:

Esto ocurre porque no hemos puesto restricciones por los lados (en este caso arriba y abajo). Es momento de añadírselas.

Una buena práctica de Programación Orientada a Objetos consiste en tener claro que cada objeto es un conjunto de atributos y funciones relativas a una entidad. Es decir, cada objeto conoce sus atributos y sabe operar con ellos, pero no tiene que saber cómo lo hacen los demás.

Pongamos un ejemplo muy simple. Digamos que tenemos una clase Persona que tiene atributos para definir nombre, apellidos, DNI… y operaciones para cambiar y consultar estos datos. Digamos que queremos escribir un objeto de esta clase Persona por pantalla. Lo primero que podemos pensar es hacer una función dentro de Persona que sea mostrarPorPantalla() y que escriba en la salida estándar los datos. Sin embargo esto viola nuestra idea de POO, y veamos por qué. Imaginemos que ahora queremos escribirlo en un fichero. Y en una base de datos. Y en un Socket. Al final acabamos con una clase Persona que tiene mil y una funciones sólo encargadas de escribir esta clase Persona en diferentes lugares. También podríamos hablar de leer de fichero, de base de datos, de Socket, de entrada estándar…
Lo correcto es que nuestra clase Persona tenga funciones para servir sus datos de las formas que se necesiten, y sean otras clases las encargadas de realizar la entrada y salida. Incluso, una clase se puede encargar de la entrada y salida de Personas en un fichero, otra en una base de datos, otra en un Socket… En proyectos complejos es interesante crear clases más simples y especializadas que operen con un conjunto relativamente pequeño de datos, para facilitar la modularidad.

Después de este tostón de buenas prácticas, volvamos a lo nuestro y analicemos las restricciones. No queremos que nuestra Raqueta se salga por arriba ni por abajo (no nos podemos mover en horizontal, así que izquierda y derecha ni nos interesan). Y, siguiendo nuestra encapsulación explicada arriba, es la Raqueta quien sabe sus atributos, por tanto es ella quien sabe si puede moverse a un sitio o no, y no las demás clases. Una de las cosas que podemos hacer es crear una nueva función para las raquetas que nos diga si podemos hacer un movimiento antes de hacerlo, que también vamos a usar en la Bola (y además va a ser igual). Para ello añadimos una nueva declaración a nuestra superclase ElementoPong:

public boolean puedoMover(int x, int y, Rect screen) {
 return screen.contains(origen.getX() + x, origen.getY() + y,
   origen.getX() + ancho + x, origen.getY() + alto + y);
}

De esta forma podemos consultar si nuestro elemento (sea Raqueta o Bola) podrá realizar el movimiento o no. Con esta función estamos pasándole el rectángulo que forma la pantalla para que la propia Raqueta  Modificamos nuestro ACTION_MOVE para que quede así:

case MotionEvent.ACTION_MOVE:
 // hemos arrastrado
 if(elementoActivo != null) {
  Raqueta r = (Raqueta) elementoActivo;
  if(r.puedoMover(0, y - origenY, new Rect(0, 0, getWidth(), getHeight())))
    r.move(0, y - origenY);
 }
 origenY = y;
 break;

Ahora podemos ejecutar de nuevo el proyecto e intentar sacar las palas de la pantalla, el resultado será similar a este:

Hasta aquí el movimiento de las Raquetas.

El siguiente paso es crear el movimiento de la Bola, la cual va a moverse automáticamente y rebotará contra los laterales y las Raquetas. Para hacerlo simple, ya que esto no es un tutorial sobre Cómo hacer un clon del Pong, vamos a simplificar mucho las posibilidades de movimiento y rebote.
En nuestro caso, vamos a permitir solamente movimientos en las diagonales principales del sistema cartesiano (es decir, ángulos de 45º, 135º, 225º y 315º). De esta forma, añadimos los siguientes atributos a nuestra clase Bola:

public static final int DCHA_ARRIBA = 1;
public static final int IZDA_ARRIBA = 2;
public static final int IZDA_ABAJO = 3;
public static final int DCHA_ABAJO = 4;

private int direccion;

Parece ser que Android y los tipos enumerados de datos no se llevan especialmente bien y que se recomienda usar static final int en lugar de enums, así que esta es la razón que influye para este diseño. El constructor de la Bola sería el siguiente:

public Bola(Coordenada origen, int ancho, int alto) {
	super(origen, ancho, alto);
	direccion = 1;
}

La variable dirección queda inicializada a 1 por ahora, pero más adelante la cambiaremos (en otra entrada). El método move() queda de la siguiente forma:

@Override
public void move(int x, int y) {
 switch(direccion) {
 case DCHA_ARRIBA:
  origen.setX(origen.getX() + x);
  origen.setY(origen.getY() - y);
  break;
 case IZDA_ARRIBA:
  origen.setX(origen.getX() - x);
  origen.setY(origen.getY() - y);
  break;
 case IZDA_ABAJO:
  origen.setX(origen.getX() - x);
  origen.setY(origen.getY() + y);
  break;
 case DCHA_ABAJO:
  origen.setX(origen.getX() + x);
  origen.setY(origen.getY() + y);
  break;
 }
}

Básicamente nos quitamos de trigonometría para hacer la explicación más simple ya que eso no tiene nada que ver con Android. Ahora es el momento de hacer que automáticamente la bola se mueva. Para ello vamos a crear una nueva clase llamada BolaMoveThread con el siguiente código:

package com.vidasconcurrentes.pongvc.juego;

public class BolaMoveThread extends Thread {

 private Bola bola;
 private boolean run;
 private int speed;

 public BolaMoveThread(Bola bola) {
  this.bola = bola;
  this.run = false;
  this.speed = 1;
 }

 public void setRunning(boolean run) {
  this.run = run;
 }

 @Override
 public void run() {
  while(run) {
   try {
    Thread.sleep(10);
   } catch (InterruptedException e) {
    e.printStackTrace();
   }
   bola.move(speed, speed);
  }
 }
}

Es muy similar al Thread que se encarga del pintado. Ahora tenemos que ejecutar el Thread. Para ello, en la clase PongGameView, añadimos como nuevo atributo este Thread, de modo que ahora tenemos todos estos atributos:

private PongGameThread paintThread;
private BolaMoveThread bolaThread; 

private ElementoPong raquetaIzda;
private ElementoPong raquetaDcha;
private ElementoPong bola;

private ElementoPong elementoActivo = null;
private int origenY;

Ahora, creamos el Thread y lo arrancamos en surfaceCreated(), de modo que el código queda:

@Override
public void surfaceCreated(SurfaceHolder holder) {
 raquetaIzda = new Raqueta(new Coordenada(50,getHeight()/2-50),20,100);
 raquetaDcha = new Raqueta(new Coordenada(getWidth()-70,getHeight()/2-50),20,100);
 bola = new Bola(new Coordenada(getWidth()/2-5,getHeight()/2-5),10,10);

 paintThread = new PongGameThread(getHolder(), this);
 paintThread.setRunning(true);
 paintThread.start();

 bolaThread = new BolaMoveThread((Bola)bola);
 bolaThread.setRunning(true);
 bolaThread.start();
}

Y por último, en surfaceDestroyed() paramos el hilo, para que deje de mover la bola y muera:

@Override
public void surfaceDestroyed(SurfaceHolder arg0) {
 boolean retry = true;
 paintThread.setRunning(false);
 bolaThread.setRunning(false);
 while (retry) {
  try {
   paintThread.join();
   bolaThread.join();
   retry = false;
  } catch (InterruptedException e) { }
 }
}

Ahora podemos ejecutar el proyecto y veremos que la bola se mueve en la diagonal del primer cuadrante. Aquí tenemos un par de fotos de la ejecución:

Como vemos, no estamos aplicando restricciones de movimiento, de modo que la pelota se va de la pantalla y sigue moviéndose, pero lógicamente no se pinta porque no está en pantalla. El siguiente paso, y último de esta entrada, consiste en hacer que la pelota rebote.

Para ello, vamos a añadirle a la Bola dos nuevos métodos. Uno se llamará puedoMover() como hicimos en la Raqueta y el otro se llamará rebota().

Comenzamos creando el método puedoMover(). Vamos a sobrecargar dicha función que viene heredada de ElementoPong para agregar funcionalidad de la siguiente forma:

public boolean puedoMover(int x, int y, Rect screen,
              Rect raquetaIzda, Rect raquetaDcha) {
 if(!puedoMover(x,y,screen))
  return false;
 if(chocaraCon(x,y,raquetaIzda))
  return false;
 if(chocaraCon(x,y,raquetaDcha))
  return false;

 return true;
}

Siendo la función chocaraCon() la siguiente:

private boolean chocaraCon(int x, int y, Rect raqueta) {
 if(raqueta.contains(origen.getX()+x, origen.getY()+y))
  return true;
 if(raqueta.contains(origen.getX()+ancho+x, origen.getY()+y))
  return true;
 if(raqueta.contains(origen.getX()+x, origen.getY()+alto+y))
  return true;
 if(raqueta.contains(origen.getX()+ancho+x, origen.getY()+alto+y))
  return true;

 return false;
}

De esta forma hacemos como cortocircuito las comprobaciones, apoyándonos en el puedoMostrar() primitivo. Básicamente comprobamos que al hacer el movimiento no nos metemos en ninguna raqueta ni nos salimos de la pantalla (de ahí el sumar x e y en cada paso).

Ahora vamos a modificar el método run() del BolaMoveThread, de la siguiente forma:

@Override
public void run() {
 while(run) {
  try {
   Thread.sleep(10);
  } catch (InterruptedException e) {
   e.printStackTrace();
  }
  if(bola.puedoMover(speed, speed, screen,
        raquetaIzda.getRectElemento(), raquetaDcha.getRectElemento()))
   bola.move(speed, speed);
 }
}

Si ejecutamos esto, llegaremos a la siguiente imagen:

Ahora tenemos que crear los rebotes, primero con las paredes y luego con las raquetas. Por ahora vamos a hacer que rebote con todos los lados de la pantalla. Para ello, creamos el siguiente método en la clase Bola:

public void rebota(int x, int y, Rect screen,
                   Rect raquetaIzda, Rect raquetaDcha) {
 if(!puedoMover(x,y,screen)) {
  switch(direccion) {
  case DCHA_ARRIBA:
   direccion = (origen.getY() - y <= screen.top) ?
     DCHA_ABAJO : IZDA_ARRIBA;
   break;
  case IZDA_ARRIBA:
   direccion = (origen.getY() - y <= screen.top) ?
     IZDA_ABAJO : DCHA_ARRIBA;
   break;
  case IZDA_ABAJO:
   direccion = (origen.getY() + alto + y >= screen.bottom) ?
     IZDA_ARRIBA : DCHA_ABAJO;
   break;
  case DCHA_ABAJO:
   direccion = (origen.getY() + alto + y >= screen.bottom) ?
     DCHA_ARRIBA : IZDA_ABAJO;
   break;
  }
 }
}

Con esto estamos haciendo una pequeña lógica de rebotes con la pantalla. Para hacernos una idea, pongamos el ejemplo de que la bola vaya hacia arriba a la derecha (DCHA_ARRIBA). En este caso sólo podemos estar chocando con la parte de la derecha o la parte de arriba. Por ahora (ya lo cambiaremos en la siguiente entrada), si choca en la derecha va a rebotar en lugar de marcar un punto. Por tanto, si estamos yendo hacia la derecha y arriba (ángulo de 45º), rebotaremos hacia abajo y a la derecha (ángulo de 315º, 45+315=360, con lo que comprobamos que está bien). Para comprobar esto miramos que la parte mas alta de nuestra Bola, después de moverse, haya atravesado la pantalla por arriba. Si no es así, es que estamos chocando con la otra opción: la derecha.
La misma idea se usa para el resto de las situaciones.

Sólo resta modificar el código del run() en el BolaMoveThread:

@Override
public void run() {
 while(run) {
  try {
   Thread.sleep(10);
  } catch (InterruptedException e) {
   e.printStackTrace();
  }
  if(!bola.puedoMover(speed, speed, screen,
        raquetaIzda.getRectElemento(), raquetaDcha.getRectElemento()))
   bola.rebota(speed, speed, screen,
        raquetaIzda.getRectElemento(), raquetaDcha.getRectElemento());
  bola.move(speed, speed);
 }
}

Falta cambiar la creación del Thread en PongGameView por:

bolaThread = new BolaMoveThread((Bola)bola, (Raqueta)raquetaIzda,
          (Raqueta)raquetaDcha, new Rect(0,0,getWidth(),getHeight()));
bolaThread.setRunning(true);
bolaThread.start();

De modo que el constructor y los atributos de BolaMoveThread ahora son:

private Bola bola;
private Raqueta raquetaIzda;
private Raqueta raquetaDcha;
private Rect screen;

private boolean run;
private int speed;

public BolaMoveThread(Bola bola, Raqueta izda, Raqueta dcha, Rect screen, Context context) {
	this.bola = bola;
	this.raquetaIzda = izda;
	this.raquetaDcha = dcha;
	this.screen = screen;
	this.run = false;
	this.speed = 1;
}

Si ejecutamos ahora, la bola rebotará indefinidamente por la pantalla atravesando las Raquetas. Ahora vamos a crear los rebotes con éstas. Para esta entrada vamos a comprobar los rebotes por todos los lados de cada raqueta, aunque en el juego final hay colisiones que no tenemos que comprobar (como el caso del lado izquierdo de la raqueta izquierda, o el del lado derecho de la raqueta derecha). Modificamos el código del método rebota() añadiendo el siguiente código:

Rect raqueta = null;
if(chocaraCon(x, y, raquetaIzda))
	raqueta = raquetaIzda;
if(chocaraCon(x, y, raquetaDcha))
	raqueta = raquetaDcha;
if(raqueta != null) {
	switch(direccion) {
	case DCHA_ARRIBA:
		direccion = (origen.getX()+ancho < raqueta.left) ?
				 IZDA_ARRIBA : DCHA_ABAJO;
		break;
	case IZDA_ARRIBA:
		direccion = (origen.getX() > raqueta.right) ?
				DCHA_ARRIBA : IZDA_ABAJO;
		break;
	case IZDA_ABAJO:
		direccion = (origen.getX() > raqueta.right) ?
				IZDA_ARRIBA : DCHA_ABAJO;
		break;
	case DCHA_ABAJO:
		direccion = (origen.getX()+ancho < raqueta.left) ?
				IZDA_ABAJO : DCHA_ARRIBA;
		break;
	}
}

Básicamente es comprobar por dónde estamos chocando, y rebotar en la dirección correcta.

Si ejecutamos el código ahora tendremos un Pong en el cual la bola rebota en todas las paredes y ambas raquetas, que era nuestro objetivo.

Aquí acaba esta segunda parte de la lógica del juego. Ha sido una entrada muy larga, pero he de decir en mi defensa que contiene mucho código para que sea fácil de seguir y comprender. Vaya, lo que es un paso a paso.

En esta entrada hemos ampliado la funcionalidad del proyecto que teníamos hasta ahora, para añadir la lógica de movimientos de las raquetas y la bola, incluyendo los rebotes de ésta con cada elemento y la creación de un Thread que se encarga de actualizar la posición de la misma.

En la siguiente entrada del ciclo Cómo crear una aplicación en Android vamos a añadir los últimos retoques: el marcador del juego y su representación en pantalla, la posibilidad de aumentar la dificultad cambiando la velocidad de la bola y posiblemente una Inteligencia Artificial muy simple.

Como siempre, podéis descargar el proyecto completo hasta el momento desde aquí (MD5: 31b264db2de4125fa01c52d4d169bc99), e importarlo a vuestro Eclipse.

¡Muchas gracias por la lectura!


Viewing all articles
Browse latest Browse all 10