martes, 20 de noviembre de 2012

Ejemplo #5: TETRIS en C++.

En el ejemplo siguiente voy a desarrollar un juego de Tetris utilizando C++.

Tetris fue diseñado por Alexey Pajitnov en 1984, en una computadora Electronika 60 cuando trabajaba en la Academia Soviética de Ciencias.

Durante el juego las piezas caen desde la parte superior de la pantalla, y se apilan a medida que se depositan sobre la parte inferior, o sobre las piezas anteriores que se van acumulando. Los tipos de piezas son 7 en total, todos con diferentes formas (algunos son el inverso de otros). El objetivo del juego es lograr colocar las piezas de forma tal que no queden espacios entre una y otra. Para esto, el jugador puede rotarlas antes de que lleguen a apilarse. Cada vez que una linea horizontal ha sido completada la misma desaparece y las piezas que estaban acumuladas sobre esta linea caen una linea hacia abajo permitiendo así que el juego continúe. Finalmente el juego concluye cuando las piezas apiladas han alcanzado la parte superior de la pantalla.



Consideraciones generales:

En principio este juego comprende varios desafíos. Vamos a enumerarlos para poder desarrollar más fácilmente su resolución. Necesitamos resolver:

-El formato de diseño de las piezas y su rotación.

-La forma en que se van a almacenar las piezas apiladas.

-El control de colisión para que las piezas puedan detener el movimiento y apilarse.

Sabemos que será necesario implementar un loop principal que mantendrá a las piezas siempre cayendo, cambiando de pieza y comenzando nuevamente cada vez que una se apila.

Ahora vamos a ir viendo como se resuelven estos tres problemas principales en cada una de las tres clases que utilizaremos para este proyecto.

Al igual que en los ejemplos anteriores, al final de este artículo se encuentra el link del proyecto, listo para descargar y compilar.

Sobre el enfoque del código, objetos y clases

El lenguaje de programación que vamos a utilizar es C++. Este es un lenguaje orientado a objetos así que vamos a hacer uso de las características propias del lenguaje.

El compilador utilizado fue GNU GCC con el IDE CodeBlocks.

Para mantener la simplicidad no utilizaré en este ejemplo ningún tipo de librerías gráficas, pero sí una técnica muy interesante de buffering doble en consola mediante el API de Windows.

Vamos a dividir el proyecto en 3 clases: la clase Piezas, la clase Tablero, y la clase Tetris que será la clase principal del juego.


La clase 'Piezas':

Vamos a tratar aquí el primer problema: el diseño y rotación de piezas.

Para saber como diseñarlas tenemos que saber primero como es que van a rotar. Esto podemos conseguirlo de dos maneras. Podemos crear una pieza de cada tipo y luego utilizar una formula para leerla en otros sentidos durante la ejecución del programa, o también podemos almacenar cada pieza con sus 4 rotaciones ya predefinidas. La forma que vamos a utilizar es esta ultima.

Habiendo definido lo anterior llegamos a la conclusión de que vamos a necesitar un array de, por lo menos, 2 dimensiones: una para la cantidad de piezas (7), y otra para cada rotación de cada pieza (4). Ahora bien, la pieza mas pequeña (el cuadrado) requiere una matriz de 2x2 para ser almacenada, y la pieza mas extensa (el palito) requiere una matriz de 4x4. Para poder utilizar un mismo array para todas las piezas vamos a tener que tomar como parámetro el tamaño de la pieza mas grande. Sin embargo, para poder realizar una correcta rotación de cada una de las piezas debemos respetar un eje de rotación central, lo cual no nos permitiría emplear una matriz de 4x4, de manera tal que tendremos que utilizar una de 5x5. Así es como finalmente el array que necesitamos para poder almacenar todas las piezas es un array de 4 dimensiones: [7][4][5][5].

Ahora necesitamos inicializar el array en la clase Piezas. Según los principios del paradigma orientado a objetos y las normas ISO C++ un array no puede (y no debería poder) ser inicializado dentro de una clase. En el plano teórico esto es correcto, desde el punto de vista práctico nos resultaría muy conveniente poder definir las piezas dentro de la clase Piezas. Ya que no podemos utilizar la notación de lista para la inicialización del array (sólo se puede usar en la declaración del mismo, que ya fue declarado en el header) y es muy impráctico cargar los valores uno por uno, vamos a realizar un pequeño hack Utilizaremos un array de caracteres y copiaremos los valores al array utilizando la función strcpy() de strings.h.

Hemos resuelto de esta manera uno de los principales problemas del proyecto utilizando un array de tipo char.

Como estos datos son de acceso privado tendremos que hacer también una función get para poder acceder a los mismos desde afuera de la clase.

A continuación vemos el código completo de la clase 'Piezas' y el header correspondiente:





La clase 'Tablero':

Para representar el tablero vamos a utilizar una matriz de dos dimensiones. Nuestro tablero de juego va a tener 20x20 y necesitamos agregar 4 bordes que marquen los límites con lo cual vamos a necesitar una matriz de 22x22.

La matriz, de tipo int, será inicializada con el valor 0 en todos los indices a excepción de los bordes que serán inicializados en 1. Esto queda expuesto en el método inicializo().

También vamos a tener que utilizar métodos get y set para poder leer y escribir los campos privados desde afuera de la clase.

El funcionamiento del tablero es simple. Las piezas iran avanzando por la pantalla, al momento de detenerse y apilarse (determinado por la detección de colisión) tomaremos los valores de posición que utilizamos para imprimir la pieza en pantalla y los utilizaremos para determinar su posición en la matriz. Una vez determinada la misma le asignaremos el valor 1 a cada indice de la matriz que coincida con un bloque sólido de la pieza. Podemos observar este mecanismo en el método imprimoEnMatriz(Piezas, int, int, int, int) que recibe como parámetros un objeto Piezas que contiene todas las piezas, el nro de pieza, el nro de rotación, la posición de la pieza sobre el eje X y la posición sobre el eje Y.

Una vez almacenados en la matriz no será un problema mostrar en pantalla el tablero con los espacios ocupados imprimiendo bloques en los lugares donde el valor sea 1 y espacios en blanco donde el valor sea 0.

  Tambiénqueda listo el tablero para poder realizar la detección de colisión de las piezas nuevas con las piezas apiladas anteriormente. Esto lo veremos en la clase 'Tetris'.

Incluiremos en esta clase dos funciones mas que servirán para comprobar si hay lineas completas y para despejar las lineas completas en caso de que las haya.

La primera recorre la matriz de abajo hacia arriba buscando las lineas donde todos los elementos tengan asignado el valor 1, si encuentra un 0 pasa a la próxima linea. Si encuentra una linea completa guarda el numero de linea. El máximo de lineas completas que se pueden realizar a la ves es 4 de manera que buscamos hasta 4 lineas completas y las almacenamos en un vector de 4 elementos. En caso de que se encuentren menos lineas completas el resto de los elementos tendrán asignado el valor 0.

La segunda función es la que despeja las lineas. Para eso se posiciona en la linea detectada en LineasCompletas[] y a partir de ahí recorre la matriz hacia arriba copiando cada linea un lugar hacia abajo.

Vemos el código completo de la clase 'Tablero' y el header:






La clase 'Tetris':

Esta es nuestra clase principal. Desde aquí vamos a instanciar a las otras clases y también es donde encontramos el loop de juego.

Un poco mas adelante en este artículo pasaremos a explicar todas las funciones pero en esta visión general quisiera apuntar al núcleo que hace funcionar el juego y que se encuentra en el corazón de esta clase.

Sabemos que para mostrar en pantalla una pieza que esta almacenada en una matriz multidimensional de 5x5 necesitamos anidar dos bucles for de 5 vueltas cada uno, de esta manera recorremos punto por punto la pieza e imprimimos un bloque donde encontremos guardado un valor 1 y un espacio en blanco donde encontremos un valor 0. Esto mostrará la pieza en pantalla.

Si además queremos que esta pieza se vaya moviendo en dirección hacia abajo tenemos que agregar un tercer bucle for que contenga los dos anteriores. Este le pasará su valor a la función que posiciona el cursor para mostrar la pieza que lo utilizará sumando su valor sobre el eje Y. A medida que el valor se vaya incrementando la pieza irá avanzando hacia abajo por la pantalla.

Nos quedan así tres for anidados: el primero desde adentro recorre las columnas de la pieza, el segundo las filas, y el tercero actualiza su posición sobre el eje Y.

Dentro del primer for que es el que itera con mas frecuencia y el que imprime los bloques y espacios es donde vamos a realizar varias operaciones.

La primera de ellas será llamar a una función que comprueba si hay colisión próxima. Para esto le pasamos a la función la posición del elemento que estamos imprimiendo en pantalla. Dentro de la función lo comparamos con las posiciones próximas hacia abajo y hacia los lados en la matriz de acuerdo a donde esta la pieza en este momento anticipándose a un posible próximo movimiento en cualquier dirección. En tal caso si la colisión es detectada hacia los laterales no nos permitirá seguir moviendo la pieza en esa dirección, y si se detecta colisión hacia abajo detendrá completamente el movimiento de la pieza para guardarla en la matriz y que pase a ser parte del tablero.

Las otras operaciones que realizamos son para comprobar los valores de la matriz del tablero mientras vamos imprimiendo la pieza en pantalla. Si, por ejemplo, en el elemento 0-0 de la pieza actual hay un espacio en blanco, pero en el tablero ese espacio esta ocupado por una pared u otra pieza entonces ponemos en pantalla el bloque correspondiente en vez de un espacio en blanco.

Queda expuesto de esta manera que es lo que necesitamos para resolver el problema de la detección de colisiones y el movimiento de las piezas.

Faltaría poder comprobar si es posible realizar una rotación o no. Esta comprobación sera realizada cada vez que el jugador presione la tecla de rotar y antes de hacer el movimiento. Vamos a resolverlo mediante una función que anticipe la posición de la pieza rotada y detecte si tiene o no espacio libre en esa posición.

Sobre el funcionamiento del buffering doble en la consola:

Vamos a utilizar una técnica muy sencilla de double buffering para poder redibujar la pantalla sin interrupciones.

Primero vamos a inicializar el buffer 1 y el buffer 2 y crear los campos necesarios para su utilización. Esto lo encontramos luego de la declaración de funciones:



Le asigno el buffer actual a buffer1, luego creo un nuevo buffer y se lo asigno a buffer2.

Será necesario crear un array que servirá para copiar temporalmente el contenido que queremos copiar de un buffer al otro. Este sera de tipo CHAR_INFO, lo cual nos permitirá tambien guardar la información de color, y tendra el tamaño del numero total de caracteres de la pantalla.

Voy a necesitar también dos campos de tipo COORD, que almacenan cada uno dos puntos (x e y) para guardar la posición de destino del buffer a copiar y sus dimensiones.

Finalmente creamos dos campos de tipo SMALL_RECT, que almacenan cada uno cuatro puntos para guardar las dimensiones de la pantalla. Utilizaremos uno para lectura y otro para escritura.

Al comienzo del método main nos vemos a encontrar con la siguiente linea:



De esta manera establecemos que el buffer que vamos a estar viendo en pantalla va a ser el segundo, o sea el que fue creado por nosotros.

A partir de aquí lo que va a ocurrir es que el programa va a estar redibujando la pantalla siempre en buffer1, que permanece oculto, y cada vez que termine de dibujar va a copiar el contenido de buffer1 en buffer2, que es el que sale por pantalla. Esto lo vamos a realizar mediante la función actualizarScreenBuffer() que vemos a continuación:



Ahora que han sido resueltos los problemas principales que plantea el desarrollo de este ejemplo presento el código completo de la clase 'Tetris':



En el header FUNC.H podremos encontrar algunas de las funciones que hemos utilizado también en otros ejemplos:



De esta manera queda presentado el proyecto completo de un juego de Tetris en C++. 

Hemos visto como resolver los principales problemas que encontramos en este tipo de juegos. También vimos características propias del lenguaje C++ y de la programación orientada a objetos, y utilizamos una técnica sencilla de double buffering en la consola mediante el API de Windows.


Links de descarga del proyecto:

https://dl.dropbox.com/u/103165598/TetrisFinal.rar



martes, 6 de noviembre de 2012

Ejemplo #4: Snake en JAVA.

Voy a demostrar en este ejemplo cómo realizar un juego en el estilo de Snake, y para esto voy a utilizar el lenguaje JAVA.

El juego Snake, o Worm según al título de la primera versión, fue escrito en 1978 por Peter Trefonas para la Tandy TRS-80 y publicado en una revista.

En las primeras épocas de las computadoras personales era muy frecuente que los videojuegos se vendieran en formato de revistas con el código del juego impreso para que el usuario lo copie en su computadora y lo ejecute.

Desde ese entonces se han realizado muchísimas versiones del juego. De entre las mas populares recordamos el NIBBLES para Qbasic y el RattlerRace que formaba parte del Microsoft Entertainment Pack para Windows 3.1.

El Snake alcanzó una segunda ola de popularidad cuando fue introducido a fines de los '90 como el juego que venía de fabrica en los celulares Nokia.



Consideraciones generales

En esta versión vamos a encontrar algunas de las funciones que ya hemos utilizado para el Ejemplo #2 en el cual realizamos un PONG en Java.

Por otra parte, el enfoque sera algo distinto ya que utilizaremos también una de las características mas importantes del lenguaje orientado a objetos que es la encapsulación. Esto significa que a los datos de una clase solo se podrá acceder mediante determinados puntos de entrada y todo lo demás sera invisible desde afuera de esa clase.

La encapsulación, junto con el polimorfismo y la herencia de clases forman las bases de lo que se conoce como programación orientada a objetos (OOP).

En Java estamos constantemente utilizando polimorfismo y herencia de clases al llamar o sobrescribir métodos de otras clases, extender clases para crear ventanas, implementar interfaces, o crear objetos utilizando constructores de distintos tipos para un mismo objeto.

En este ejemplo vamos a utilizar 3 clases:

La clase JuegoSnakeJava es la clase principal, la que contiene los loops del juego y desde donde instanciamos los otros objetos.

La clase Snake es la que contiene todos los campos y métodos relacionados con el comportamiento de la serpiente.

La clase Frutita que sirve para crear y controlar las frutas que serán el alimento de nuestra serpiente.


El principal problema que nos encontramos en este juego es el movimiento de la serpiente.
La solución es en realidad bastante sencilla. Vamos a representar el cuerpo de la serpiente mediante un array, o mejor dicho un ArrayList que es uno de los tipos de listas que nos ofrece el lenguaje Java. La ventaja del ArrayList sobre un array tradicional es que no tenemos que definir un numero de elementos al declararlo, es decir que podemos ir agregando elementos y el tamaño de la lista se redefine dinámicamente. El ArrayList funciona en este sentido como las List de C#.

Dentro del ArrayList vamos a almacenar objetos de tipo Point que son muy convenientes ya que representan un punto con coordenadas X e Y que es justo lo que necesitamos para representar cada parte del cuerpo de la serpiente. Cada vez que la serpiente coma una fruta haremos crecer el cuerpo agregando un punto a la lista.

Para resolver el problema del movimiento utilizaremos un bucle for para ir recorriendo todos los puntos de la lista de atrás para adelante, copiando cada punto a la posición del que esta delante en la lista hasta llegar al elemento 1 que se copiará donde estaba la cabeza (que es el punto 0 en la lista) y la posición del punto 0 será actualizada entonces por fuera del bucle.

Para la parte de las frutas necesitaremos crear puntos dispuestos aleatoriamente por la pantalla. Esto no debería ser un problema.

Finalmente vamos a tener que realizar la detección de colisión necesaria para saber cuando la serpiente ha comido alguna fruta o cuando ha alcanzado alguna parte de su propio cuerpo. Para esta parte vamos a necesitar hacer comparaciones entre las posiciones de los distintos puntos, de la fruta y de las partes del cuerpo de la serpiente.

Ahora que sabemos como resolver los problemas del juego vamos a ver el código.


Análisis del código

Comenzaré con la clase Snake.
Veamos el código completo de la clase:



Primero declaro un ArrayList de objetos de tipo Point que va a tener el nombre largo. Alli almacenaremos todas las partes del cuerpo de la serpiente. Luego las variables snakeX y snakeY que me servirán para controlar y mantener actualizada la posición.

Todos los campos son de tipo privado. Solo necesitaremos acceder a largo desde afuera de la clase y para eso haremos un método get.

En el constructor voy a agregar el primer punto ubicado en el centro de la pantalla (20, 15) para comenzar. En breve explicaré por que 20, 15 es el centro de la pantalla.

Después del constructor vemos un metodo getLargo(). Esta función nos servirá para poder acceder al campo largo desde afuera de la clase.

La siguiente función dibujoSnake(Graphics) hace uso de un bucle for que recorre la lista y muestra en pantalla cada uno de los puntos que forman el cuerpo de la serpiente utilizando el objeto de gráficos g que recibe como parámetro.



El punto p representa la posición del punto que estamos mostrando en cada vuelta. Cada punto tiene una dimensión de 20x20 y multiplicamos también sus coordenadas por 20 al mostrarlo para subdividir la pantalla. De esta manera las dimensiones de nuestro campo de juego serán de 40x30, es decir de 800x600 / 20.


La función muevoSnake() contiene la lógica necesaria para mover la serpiente.



El for recorre todos los puntos que forman la cola de la serpiente comenzando desde el último y moviendo cada punto a la posición del que le antecede en la lista. Tenemos en cuenta que largo.size() nos devuelve la cantidad de elementos de la lista completa y como la lista comienza en 0 al igual que un array común entonces n=largo.size()-1. Dejamos el punto 0 (la cabeza) fuera del for y actualizamos su posición sumando los valores de snakeX y snakeY que moverán el punto hacia una u otra dirección según sean positivos o negativos.


Creamos ahora una función que agrega un punto a largo a la que llamaremos cada vez que necesitamos hacer crecer la cola de la serpiente.



La ultima función de la clase Snake es void direccion(string). Cuando llamamos a esta función le pasamos el sentido en el que nos queremos mover y lo almacenamos en el string d. El switch evalúa el valor de d y asigna los valores correspondientes en cada caso. Observamos que si un valor está en 0 significa que la función de movimiento le sumará 0 al movimiento en ese sentido, es decir que no se moverá. Para mover entonces el punto hacia un lado le tenemos que asignar el valor 1, o -1 para moverlo hacia el lado contrario ya que la función que lo mueva le estará restando valor a la posición del punto al sumarle un número negativo.



Pasamos a la clase Frutita. Veamos el código:



La fruta va a ser representada por un campo de tipo Point llamado frutita. Declaramos también un objeto de tipo Random para generar los números aleatorios que necesitamos para determinar la posición de la fruta.

Una vez declarados el constructor va a instanciar los objetos.

La función nuevaFrutita() le asigna a frutita.x y un valor aleatorio entre 0 y 39 y a frutita.y un valor entre 1 y 29. De esta manera la fruta se traslada hacia una nueva posición creando la ilusión de una nueva fruta.




La próxima función es dibujoFrutita(Graphics). Imprime en la posición (frutita.x, frutita.y) un circulo de 20x20. Es necesario multiplicar los valores de frutita.x y frutita.y por 20 para poder trasladarlos a nuestro campo de juego.



Finalmente creamos la función getFrutita() para poder acceder al valor del punto frutita desde afuera de la clase.



Ahora estamos en condiciones de pasar a la clase principal: JuegoSnakeJava.

A continuación presento el código completo y luego explicaré en detalle cada función:



Al igual que en el Ejemplo #2, vemos que la clase JuegoSnakeJava extiende de Jframe para crear la ventana del juego y que implementa la interfaz KeyListener para controlar los eventos relacionados con el teclado.

Instanciamos un objeto JuegoSnakeJava en el punto de entrada main.

A continuación definimos las propiedades de la ventana en el constructor:



Luego de definir las propiedades vemos que en la siguiente linea: this.createBufferStrategy(2) creamos un buffer doble para poder redibujar la pantalla sin cortes.

Para poder utilizar la interfaz KeyListener tenemos que agregar también la siguiente linea en el constructor: this.addKeyListener(this) .

Llamamos ahora a inicializoObjetos() para crear los objetos Snake y Frutita y una vez que han sido creados podemos comenzar el juego.

Vamos a hacer a continuación un loop infinito que en cada vuelta va a llamar a juego() y desde allí vamos a manejar toda la lógica, y luego llamamos a sleep(), que es exactamente igual a la función que utilizamos en el PONG, para producir la demora necesaria entre una vuelta y otra.



Vemos la función juego():




Primero vemos que llamamos al método muevoSnake() de la clase Snake para mantener siempre actualizada la posición de todas las partes del cuerpo de la serpiente.

Luego llamamos a chequearColision(). Desde ahí vamos a controlar si la serpiente alcanzo una fruta, una pared, o su propio cuerpo.

Finalmente con dibujoPantalla() ponemos en pantalla todo lo que esta ocurriendo.


Vamos a ver como funciona chequearColision():



Comprobamos primero si la posición de la cabeza de la serpiente ha alcanzado la posición de la fruta. En tal caso llamo a nuevaFrutita() para crear una fruta nueva y a crecerColaSnake() para incrementar el tamaño de la serpiente.

El segundo bloque if comprueba si la serpiente ha atravesado alguno de los limites de la pantalla. En tal caso inicio un nuevo juego. Recordemos que la pantalla es de 800x600 pero la hemos subdividido en bloques de 20x20 de manera que manejamos un campo de juego de 40x30.

El siguiente bucle for pasa por todos los puntos que forman la cola de la serpiente (omitiendo el punto 0 que es la cabeza) y comprueba si la posición de la cabeza de la serpiente es igual a la de alguna de sus partes, en tal caso detecta una colisión y comienza el juego nuevamente.


La función dibujoPantalla() es idéntica a la que utilizamos anteriormente en el ejemplo del PONG. Aquí vamos a inicializar los objetos gráficos para poder usarlos con las otras funciones.



Inicializamos primero los objetos g y bf.

Luego vamos a realizar todas las instrucciones dentro de un try por si el programa arroja alguna excepción.

A continuación le asignamos a g el valor devuelto por bf.getDrawGraphics(). De esta manera obtenemos un objeto de tipo GRAPHICS2D para poder utilizar el buffer doble.

Ahora llamamos entonces a las funciones que imprimen en pantalla los puntos, la serpiente, y la fruta, pasándoles como parámetro siempre el objeto de gráficos g.

Una vez que hemos llamado a las funciones anteriores y ya no necesitamos el objeto g nos deshacemos de él mediante g.dispose().

Para terminar mostramos todo en pantalla con bf.show() y sincronizamos con el refresh rate de la pantalla con sync().


muestroPuntos() utiliza al ser llamada desde dibujoPantalla() el objeto de gráficos g para mostrar en pantalla la puntuación.




Pasamos a sleep(). Esta función es exactamente igual a la utilizada en el Ejemplo #2 pero vamos a repasarla:



Primero creamos un punto de referencia con la suma de System.currentTimeMillis() y el tiempo de demora y lo almacenamos en goal. Luego comparamos el valor de System.currentTimeMillis() produciendo una demora hasta que este alcance a goal.


Para concluir observamos que hemos tenido que sobrescribir 3 métodos. Esto es necesario para poder utilizar la interfaz KeyListener. Como sólo vamos a hacer uso del primero de los tres, los otros dos métodos los dejamos vacíos.





Guardamos en tecla el valor del código de tecla devuelto por e.getKeyCode();

El switch que le sigue llama al método direccion(string) de la clase Snake pasándole el parámetro correspondiente de acuerdo con el valor de tecla, o llama a System.exit(0); y finaliza la ejecución del programa si presionamos la tecla 'e'. Al pasarle 0 como parámetro a System.exit(); estamos indicando que la ejecución ha finalizado sin errores.


Hemos llegado así al fin de esta exposición del código. He tratado de mantener un estilo de programación acorde al paradigma de objetos y al lenguaje que utilizamos para realizar el juego.

Muchas de las ventajas de la programación orientada a objetos pueden apreciarse mejor en sistemas y aplicaciones de mayores dimensiones, pero aún en un ejemplo pequeño como éste vemos cuanto ganamos en organización y claridad del código.


Link para descargar el código:

clases:
https://dl.dropbox.com/u/103165598/snakeJava-src.rar

SnakeJava.JAR:
https://dl.dropbox.com/u/103165598/SnakeJava.jar




sábado, 3 de noviembre de 2012

Ejemplo #3: Invasores del espacio.

En este tercer ejemplo voy a mostrar cómo hacer un juego en el estilo de Space Invaders.

Creado en 1978 por Tomohiro Nishikado para Taito, el concepto del juego es simple: una nave controlada por el jugador se enfrenta a una flota de naves enemigas que se mueven juntas por la pantalla para esquivar los disparos y a su vez atacar al jugador.

Para resolver este programa vamos a utilizar nuevamente el lenguaje C.
No haremos uso de librerias graficas asi que todas las naves y los disparos seran representados por distintos caracteres.

Consideraciones generales

En principio se nos plantean dos problemas principales:

El primero es el movimiento de las naves enemigas. En esta versión del juego vamos a crear una flota de 35 naves, ordenadas en 7 columnas de 5 naves cada una.

Las naves serán representadas por el carácter ' * ' y serán almacenadas en una matriz de dos dimensiones de 7x5.

Les vamos a asignar una posición inicial. Estamos utilizando una pantalla de 80x24 así que en el eje horizontal las naves enemigas estarán en las columnas 20, 30, 40, 50, y 60 y sobre el eje vertical estarán en las filas 2, 4, 6, 8, 10. Tenemos que tener en cuenta esto para entender los valores de los bucles que imprimen las naves en pantalla.

Para poder solucionar el movimiento le vamos a pasar a la función que imprime la flota enemiga en pantalla dos valores que pueden ser 0 o 1, y se los vamos a sumar a la posición sobre los ejes X e Y. De esta forma tenemos 4 posibles combinaciones: 0-0, 1-0, 1-1, 0-1 que nos permiten realizar una vuelta completa sumando 0 o 1 a X e Y para modificar la posición de las naves.

Esto es fácilmente modificable para realizar otros movimientos pero por ahora vamos a quedarnos con una vuelta simple.

El segundo problema que tenemos que resolver es como vamos a hacer el seguimiento de los rayos disparados por las naves enemigas.

Por empezar en cada vuelta completa de las naves vamos a elegir aleatoriamente una que va a realizar un disparo. La selección también podría ser dependiendo de la zona en la que se encuentre la nave del jugador, pero lo vamos a hacer aleatorio para restarle algo de dificultad al juego.

La nave seleccionada en cada vuelta para atacar va a ser almacenada en un vector de acuerdo a la columna a la que pertenece, en la posición que le corresponde de acuerdo con su número. En la vuelta siguiente se realiza una comprobación a través del vector para saber que nave realizó un disparo, en ese caso llamamos a una función para inicializarlo. Una vez inicializado apagamos su valor en el vector de inicialización y le asignamos un valor en otro vector que guarda los rayos disparados en curso. Luego se realiza una comprobación que recorre este último vector y por cada rayo en curso llama a una función que lo imprime en pantalla y actualiza su posición.

Hemos resuelto así los dos mayores problemas que nos presenta el juego.


Análisis del programa

A continuación presento el código completo y luego pasare a explicar cada función:


En principio nos encontramos nuevamente con la función sleep() que ya ha sido explicada muy en detalle en el Ejemplo #1 así que no volveré a explicarla aquí.


Vemos también una función getrand(). Esta función contiene una fórmula para producir un número aleatorio entre 0 y 6. Para utilizarla debemos incluir la linea srand(time(0)) al comienzo del programa ya que para generar números aleatorios utilizará la hora del sistema.




Otra pequeña función que he incluido en este ejemplo es gotoxy(). Hasta ahora veníamos utilizando la función gotoxy que se encuentra en conio.h. Creamos aquí nuestra propia gotoxy() utilizando la librería windows.h. También hemos reemplazado los llamados a clrscr() por system(“cls”). Esto hace nuestro código un poco menos orientado a Borland y mas compatible en otros compiladores. Seguiremos utilizando igualmente la función kbhit() de conio.h.



Nos encontramos nuevamente con la función leoTecla() que ya ha sido utilizada en el Ejemplo #1. Vemos que ha sido un poco modificada para adaptarse a nuestras necesidades en este juego. Observemos el case DISPARA. Si el jugador pulsa la barra de espacio (representada por la constante DISPARA) comprueba si hay un rayo del jugador en curso. Si lo hay ignora la llamada. Si no hay un rayo en curso activa uno nuevo asignándole el valor 1 a rayoON y guardando la posición inicial de acuerdo con la posición del jugador.



Las funciones menuPrincipal() y menuFin() son bastante sencillas. La primera es llamada al comienzo de un nuevo juego. Según el nivel de dificultad seleccionado almacenará 0 (difícil), 1 (medio) o 2 (fácil) en la variable NUEVOATAQUE para tomar como valor de referencia. Esto hará que los disparos enemigos se habiliten cada 0, 1 o 2 vueltas.


menuFin() es llamada luego de finalizado el juego para saber si se desea continuar jugando:



La función inicializoArrays() es llamada al comenzar un juego nuevo. Inicializa en 0 los arrays que guaran la cantidad de rayos enemigos disparados y sus respectivos estados y posiciones.



inicializoNaves() lo que hace es inicializar la matriz que representa las naves enemigas asignándoles el carácter ' * ' con el que serán representadas en pantalla.




El rayo del jugador es controlado por dos funciones:
La primera, comprueboRayo() es llamada en cada vuelta del loop principal. Lo que hace es comprobar si hay un rayo disparado, y en tal caso llama a rayo() para imprimirlo en pantalla y luego actualiza su posición.


dibujoJugador(int) recibe como parámetro la posición del jugador sobre el eje X. El primer bucle for dibuja la nave del jugador, representada por 3 caracteres del valor almacenado en x.
El segundo for comprueba si la nave del jugador fue alcanzada por alguno de los rayos enemigos. En ese caso asigna el valor 1 a la variable GameOver para terminar el juego.


A continuación vamos a ver las funciones mas complejas que son las que tratan con los problemas expuestos en la presentación.

La función void escuadronVuelta(int, int) recibe como parámetros dos valores, que van a ser 1 o 0, para representar las 4 posibles posiciones que forman una vuelta completa de la flota enemiga mediante las combinaciones 0-0, 1-0, 1-1, 0-1.

Utilizaremos 2 bucles for anidados para imprimir las naves en pantalla, con sus valores modificados para que nos sirvan como referencias a las posiciones que necesitamos alcanzar.

Como queremos que las naves estén separadas cada 10 espacios horizontales y 2 espacios verticales utilizamos los valores de ambos for para alcanzar las posiciones deseadas e imprimimos la matriz en pantalla por orden de filas.

La llamada a printf imprime el valor de la matriz correspondiente compensando con los valores de x e i.



Las próximas dos funciones controlan los rayos enemigos:

void disparaEnemigo(int) toma como parámetro el numero de nave enemiga que tenga un disparo pendiente de ser inicializado.

Comienza la función comprobando si la nave que esta primera en la columna seleccionada (la que esta mas arriba en la matriz) existe o si fue eliminada, para poder realizar el ataque. Si esta condición no se cumple, en la próxima vuelta del for intentará con la que esta detrás.

Si se cumple, entonces rayoEnemX[] y rayoEnemY[] contienen las posiciones de todos los rayos enemigos. Les asignamos la posición del rayo nuevo de acuerdo a la posición de navequeAtaca en la matriz. Luego imprimo en pantalla el primer rayo.

A continuación actualizo el valor de proxAtaque para llevar la cuenta de vueltas hasta el próximo ataque.

Ahora que el rayo ha sido inicializado el seguimiento será guardado en el vector rayoDisparado[] asignandole el valor 1 en el lugar que le corresponde y lo apago en rayoEnemigoON[] para que no vuelva a ser inicializado hasta que esa nave no dispare nuevamente.



La próxima función es void disparo(int), que actualiza la posición del disparo enemigo e imprime el disparo en pantalla en su nueva posición. Si el rayo alcanzo el borde de la pantalla lo elimina asignandole el valor 0 correspondiente a la posición del número de nave que recibe como parámetro en el vector rayoDisparado[] que almacena cuales son las naves enemigas que tienen rayos disparados activos.



Ahora hemos llegado a la función principal: void naves(). Esta función es llamada desde el loop del juego en el main(). Contiene a su vez un bucle de 4 vueltas que forman el ciclo completo de movimientos de las naves enemigas.

Comienza comprobando que la nave seleccionada por getrand() para atacar no tenga un disparo activo (si es la primer vuelta del juego no realiza ataque por que aun no se ha seleccionado ninguna) y que proxAtaque haya alcanzado el valor de NUEVOATAQUE, en ese caso asigna un 1 en su posición correspondiente, de acuerdo a la columna de la nave que ataca, en rayoEnemigoON[] permitiendo así que el rayo sea inicializado.

La primera serie de instrucciones del bucle limpia la pantalla e imprime las naves en la posición correspondiente llamando a escuadronVuelta(), pasando como parámetro dos ints(0 o 1) para generar las 4 posiciones posibles.

La llamada a comprueboRayo() es para comprobar si hay un rayo disparado por el jugador, en tal caso llama a rayo() para dibujarlo y luego actualiza su posición.

En el for siguiente primero se comprueba si se activó un rayo enemigo y en ese caso se llama a disparaEnemigo() para inicializarlo. Luego recorremos las naves que tienen disparos activos en curso llamando a disparo() para mostrarlos en pantalla y actualizar su posición.

Las llamadas a leoTecla() y dibujoJugador(int) realizan la lectura del buffer de teclado e imprimen en pantalla la nave del jugador respectivamente.

muestroScore() solo es responsable de mostrar en pantalla la puntuación.

En la comprobación siguiente, si GameOver==1 entonces salgo del loop antes que termine la vuelta completa de las naves enemigas.

Finalmente producimos la demora necesaria con sleep() y le asignamos a navequeAtaca un numero de nave (de columna en realidad) aleatorio seleccionado por getrand().



Para terminar nuestro análisis vemos la función main().

Toda la función esta encerrada en un bucle que nos permite que el juego continúe si el jugador selecciona la opción al terminar.

En principio seteamos el nivel de dificultad de acuerdo al valor devuelto por la llamada a menuPrincipal(). NUEVOATAQUE toma el valor de nivelDeDif. Esto es simbólico ya que podríamos asignarlo directo a NUEVOATAQUE pero utilizando una variable mas el código se lee con mas facilidad.

Llamamos luego a las funciones que inicializan las naves y los arrays.

Después de inicializar GameOver en 0 y llamar a srand() comienza el loop del juego que se repetirá mientras GameOver se mantenga en 0.

Dentro del loop llamo a naves() y al salir de la función se incrementa el cuenta vueltas proxAtaque o se le asigna 0 si ya alcanzó a NUEVOATAQUE.

Saliendo del loop del juego se permite al jugador elegir si desea seguir jugando, en tal caso resetea el score y el nivel de dificultad y vuelve a comenzar el ciclo principal, caso contrario termina el juego.



Finalizamos de esta manera la revisión del código de Invasores del espacio.

He optado en este ejemplo por un estilo de código un poco mas extenso pero que me permita realizar modificaciones y expandir la jugabilidad con mas facilidad.




Link para descargar el código:

https://dl.dropbox.com/u/103165598/InvasoresDelEspacio.c