Programando SNAKE en c++

Por David Mendoza Velis.
Un agradecimiento especial a Salvador Pozo.
Portada

Lógica del juego

El juego consta de en 4 puntos importantes:

  1. El cuerpo de la serpiente esta constituido por varias partes, cada una con distintas coordenadas, por lo que debemos plantearnos la idea de que necesitaremos de una matriz para guardar las coordenadas de cada parte de la serpiente.
  2. El movimiento de cada parte del cuerpo esta determinada por la trayectoria que tiene la cabeza del cuerpo o bien, cada parte del cuerpo tendrá que tomar la posición de la parte de la serpiente que le suscede.
  3. Entra en juego un objeto al que, por la idea del juego, lo veremos como la comida de la serpiente. La idea es sencilla, cada vez que la cabeza de la serpiente “choca” con la comida esta aparece en un lugar distinto y el tamaño de la serpiente aumenta en 1. En este punto podemos meter el concepto de dificultad que va aumentando a medida que la serpiente ha comido un determinado número de bolitas (la dificultad consiste en aumentar la velocidad del juego).
  4. El juego acaba cuando la serpiente choca con los límites de la pantalla o con su propio cuerpo.

Ahora incluiremos las librerías que usaremos.

#include <windows.h>
#include <conio.h>
#include <cstdlib>
#include <cstdio>
#include <ctime>

Ahora pondremos estas 2 funciones accesibles a cualquier parte del programa (globales).

void gotoxy(int x, int y)  {
    HANDLE hCon;
    COORD dwPos;

    dwPos.X = x;
    dwPos.Y = y;
    hCon = GetStdHandle(STD_OUTPUT_HANDLE);
    SetConsoleCursorPosition(hCon,dwPos);
}

void OcultaCursor() {
    CONSOLE_CURSOR_INFO cci = {100, FALSE}; // El segundo miembro de la estructura indica si se muestra el cursor o no.

    SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), &cci);
}

La primera es una replica de la función gotoxy la cual viene en los compiladores de Turbo C y también en Borland C++ (De ahí que hallamos incluido la librería windows.h). Lo que hace esta función es situar el cursor en las coordenadas x , y que tiene como parámetros, lo cual nos permitirá imprimir cada parte de la serpiente en las coordenadas deseadas.

La segunda es para eliminar o no imprimir el molesto parpadeo del cursor.

Empecemos a darle detalles visuales al juego.

Crearemos otra función global que nos imprimirá en pantalla los límites del escenario.

void pintar(){
     // Líneas horizontales
     for(int i=2; i < 78; i++){
        gotoxy (i, 3); printf ("%c", 205);  // los números hacen referencia al código acsii
        gotoxy(i, 23); printf ("%c", 205);
     }
     //Líneas verticales
     for(int v=4; v < 23; v++){
        gotoxy (2,v);  printf ("%c", 186);
        gotoxy(77,v);  printf ("%c", 186);
     }
     // Esquinas
     gotoxy  (2,3);    printf ("%c", 201);
     gotoxy (2,23);    printf ("%c", 200);
     gotoxy (77,3);    printf ("%c", 187);
     gotoxy(77,23);    printf ("%c", 188);
}

De lo anterior acabamos de definir que la serpiente solo se podrá mover en le eje X de (2,77) y en el eje Y de (4,22). Nótese que los intervalos son abiertos pues en dado caso que la coordenada x de la cabeza de la serpiente fuese 2 o 77 lo interpretaríamos como un choque con los limites del escenario y por tanto “game_over”.

Podemos probar lo anterior haciendo la función main() de la siguiente manera.

int main(){

   OcultaCursor();
   pintar();
   return 0;
}

Si se esta usando compiladores que cierren la ventana inmediatamente al hacer return 0; necesitaran incluir la librería cstdlib y poner system(“pause”); debajo de la invocación de la función pintar() dentro de la función main().

Definiendo cada objeto del juego

Nota:

Lo siguiente se pondrá debajo de la función pintar(){ ….. }.

Dividiremos el juego en 2 objetos, comida y serpiente.

Comida

Haremos una clase que llamaremos comida. En esta clase definiremos las variables y funciones que necesitemos para poder definir de una manera precisa a un objeto de esta clase. Como se ya se mencionó en el punto 3 de la lógica del juego, necesitamos claramente dos variables que nos definan las coordenadas donde estará la comida de igual forma requerimos de una función miembro que contenga las sentencias necesarias para pintar dicho objeto en pantalla.

Detengámonos a pensar y supongamos que tenemos el juego funcionando, veremos que al pasar la serpiente por encima de la comida esta desaparece y se la asignan nuevas coordenadas, esto quiere decir que existe una comunicación entre ambos objetos, lo que nos tiene que llevar a pensar en hacer dos funciones que nos regresen las coordenadas del objeto para poder lograr dicha comunicación y por ultimo requerimos de una función miembro que le asigne nuevas coordenadas a la comida cada vez que se requiera.

De lo anterior podemos definir la clase comida de la siguiente manera:

class Comida{
   int xc,yc;                      // coordenadas de la comida
   public:
          Comida(int _x ,int _y);  // constructor
          void pintar_comida();
          int X() {return xc;}  // Interfaz
          int Y() {return yc;}
          void AsignaCoordenadas(int _x, int _y) {
            xc = _x;
            yc = _y;
          }// Asigna nuevas coordenadas a la comida.

};
Nota:

Ahora debajo de la clase procederemos a definir el constructor y las funciones miembro.

Comida::Comida(int _x ,int _y) : xc(_x), yc(_y) {}

El constructor tiene como parámetros dos enteros que se asignaran como las coordenadas de la comida.

void Comida::pintar_comida(){
    gotoxy(xc,yc); printf("%c",4); // pintar comida
}

Usamos la función gotoxy para situarnos en las coordenadas de la comida y se imprime un carácter haciendo referencia al código ascii, en este caso el numero 4 corresponde a un rombo.

Serpiente

Como ya se mencionó, requeriremos del uso de una matriz para guardar la posición de cada parte de la serpiente, pero, ¿cómo lograremos esto?

Supongamos que en principio el cuerpo de la serpiente sólo consta de 3 partes y supongamos que las coordenadas son estas:

Parte1  (6,5)
Parte2  (5,5)
Parte3  (4,5)

Podemos guardar la parte 1 en una matriz de este estilo int cuerpo[1][2] haciendo cuerpo[0][0] = 6 y cuerpo[0][1] = 5. Ahora, si modificamos las dimensiones de la matriz cuerpo podemos guardar los 3 pares ordenados, así pues la matriz debería ser declarada así: int cuerpo[3][2]. Entonces podríamos guardar el segundo par ordenado en los espacios cuerpo[1][0],cuerpo[1][1] y el tercer par ordenado en los espacios cuerpo[2][0],cuerpo[2][1].

En principio no sabemos que tan bueno es el jugador, por lo que no podemos suponer que sólo llegará a un determinado tamaño, así pues lo que podemos hacer es darle espacio a la matriz para almacenar hasta 100 pares ordenados, memoria que después de dedicarle tiempo al diseño de la dificultad nos parecerá excesiva.

Ahora necesitaremos de variables que nos definan y ayuden a simular el comportamiento de la serpiente.

  1. Coordenadas para el control de la posición de la cabeza.
  2. Una variable que nos facilite modificar el tamaño de la serpiente.
  3. Variables para el control del marcador (puntos) y la velocidad del juego.
  4. Variable para controlar en que dirección debe moverse la serpiente según la tecla que se presione, por lo que también hará falta una variable para el control del teclado.
  5. Ahora, como se usará una matriz para guardar el cuerpo requeriremos de una variable para el control del índice, cosa que no nos debe tomar por sorpresa si se tiene algo de noción en este tema.

Teniendo claro esto ahora debemos pensar que tipo de funciones miembro debe tener la clase para lograr simular el comportamiento de la serpiente.

  1. Función para pintar el cuerpo en pantalla y otra para ir borrando las posiciones anteriores pues estas no se borraran por si solas, eso nos toca a nosotros.
  2. Necesitamos de una función que nos vaya guardando y actualizando las posiciones de cada parte del cuerpo dentro de nuestra matriz.
  3. A esta función le daremos un nombre más que apropiado “Game_over”, con esta función determinaremos si la serpiente ha chocado con los límites del escenario o con su propio cuerpo para poder terminar el ciclo del juego.
  4. Haremos una función con la que podamos definir la dirección en que se moverá la serpiente según los eventos del teclado.
  5. A esta función le llamaremos cambiar_posición la cual hará los incrementos pertinentes de las coordenadas dependiendo de la dirección que tenga la serpiente.
  6. Regresando al tema de la comunicación entre objetos, necesitaremos de una función que llamaremos “comparar_coordenadas” que tendrá como parámetro un objeto de la clase comida, para poder hacer uso de las funciones miembro de este objeto y llamar a las funciones que nos regresan las coordenadas de la comida, y poder compararlas con las del objeto serpiente y, en base a estas comparaciones, hacer los cambios necesarios a ambos objetos.
  7. Requerimos de una función que nos cambie la velocidad del juego en base a los puntos que se tengan.
  8. Por ultimo haremos una función para imprimir en pantalla el marcador.

Ahora, en base a lo anterior, la definición de la clase serpiente podría ser la siguiente:

class serpiente{
     int cuerpo[200][2];
     int n;                     // Variable para controlar los índices del cuerpo
     int x , y;                //  coordenadas de la serpiente
     int tam;                // Tamaño del cuerpo
     int dir;                  // Variable control de la dirección de la serpiente
     int score;
     int h;                     // control para la velocidad

public:

     int velocidad;
     char tecla;
     serpiente(int _x , int _y);   // Constructor
     void guardar_posicion();
     void dibujar_cuerpo() const;
     void borrar_cuerpo() const;
     bool game_over();
     void teclear();
     void cambiar_pos();
     void comparar_coordenadas(Comida &);
     void cambiar_velocidad();
     void puntos() const;

};

Constructor

serpiente::serpiente(int _x , int _y) : x(_x), y(_y), tam(3), n(0), dir(3), score(0), h(1), velocidad(100) {}

El constructor le asigna el valor de los parámetros a las coordenadas de la cabeza. Define que el tamaño de la serpiente sea 3. Hace la variable para el control de los índices de la matriz igual a cero. Hace que dirección tome el valor de 3, en este caso definiremos que dir = 3 sea para mover la serpiente a la derecha. Los puntos se inicializan en cero.

Más adelante veremos por qué hacemos h = 1 y velocidad = 100, por el momento no hace falta entender esta parte.

Nota:

Ahora nos tomaremos el tiempo necesario para pensar cómo abordar el problema del desarrollo de la función para guardar las posiciones de las distintas partes del cuerpo de la serpiente dentro de la matriz, pues a mi parecer es la parte más delicada del juego.

Teniendo en cuenta que el constructor ya le ha asignado valores a las coordenadas de la cabeza un primer intento por guardar estas coordenadas dentro de la matriz sería este:

cuerpo[0][1] = x;
cuerpo[0][2] = y;

Ahora, la posición de la siguiente parte del cuerpo esta en función de la posición anterior de la cabeza, esto es, antes de aplicarle los incrementos necesarios para moverla.

Supongamos que la cabeza se ha movido una unidad a al derecha, entonces los valores que están guardados en cuerpo[0][1] y cuerpo[0][2] corresponden a las coordenadas anteriores de la cabeza, así pues, para no perder estos datos que bien podemos usarlos para definir otra parte del cuerpo, haremos que las nuevas coordenadas se guarden en un índice mayor de la matriz lo cual lo logramos haciendo lo siguiente:

cuerpo[1][1] = x;
cuerpo[1][2] = y;

Después de esto se hará otro cambio en las variables x e y para mover la cabeza digamos otra unidad a la derecha, así pues, los valores guardados en estos 4 espacios de la matriz corresponden a las coordenadas de las 2 partes del cuerpo detrás de la cabeza y para no perder estos datos haremos que las nuevas coordenadas se guarden en los espacios de la matriz con primer índice 2, es decir:

cuerpo[2][1] = x;
cuerpo[2][2] = y ;

Para este entonces tendremos guardados 3 pares ordenados en la matriz.

Supongamos que la variable que nos determina el tamaño de la serpiente es tal que solo puede permitir guardar 3 pares ordenados en la matriz, entonces una vez que se han usado estos 3 espacios se debe empezar a sobrescribir la matriz para no usar espacios de memoria que no hacen falta usar, es decir, cuando se hagan los nuevos incrementos de las variables x e y estos nuevos datos debemos guardarlos en cuerpo[0][1] y cuerpo[0][2] respectivamente pues para este entonces los datos guardados en estos espacios serían las coordenadas de la cola de la serpiente las cuales ya no hace falta tenerlas pues al avanzar de nuevo la cabeza un espacio a la derecha la cola también debería estar un espacio más a la derecha con lo que las coordenadas guardadas en cuerpo[1][0] y cuerpo[1][1] ahora serían la cola, por lo que los datos guardados en cuerpo[0][1] y cuerpo[0][2] son obsoletos y podemos prescindir de ellos, así que usaremos eso espacios para guardar las nuevas coordenadas de la cabaza:

cuerpo[0][1] = x;
cuerpo[0][2] = y;

Y en fin, esta idea aplica independientemente del tamaño.

Entonces, teniendo en cuenta que el constructor ha inicializado a la variable “n” para el control del índice en cero y el tamaño en 3, podemos aplicar lo anterior dentro de la función miembro “guardar_posicion” de la siguiente manera:

void serpiente::guardar_posicion(){
     cuerpo[n][0] = x;
     cuerpo[n][1] = y;
     n++;
     if(n == tam)      // sobrescribimos la matriz cuerpo
          n=0;
}

(Les adelanto que los cambios de los valores de las coordenadas x e y se efectuaran dentro de la función cambiar_posicion ,la cual será llamada dentro de la rutina del juego).

Ahora hay que definir la función miembro para pintar la serpiente en pantalla:

(La idea es sencilla, sólo hay que situarse en las coordenadas de la cabeza y pintar un asterisco).

void serpiente::dibujar_cuerpo() const{
     gotoxy(x,y); printf("*"); // pintamos la cabaza
}

Como ya mencione, a nosotros nos toca la parte de borrar el cuerpo pues esta acción no se hará por si sola.

Este parte es más delicada de lo que se puede pensar en un principio.

Consideremos la siguiente definición parcial para esta función:

void serpiente::borrar_cuerpo() const{
     for(int i = 0 ; i < tam ; i++){
         gotoxy(cuerpo[i][0],cuerpo[i][1]); printf(" ");
     }
}

Lo que hace esta función es recorrer toda la matriz en los espacios que están ocupados interpretarlos como coordenadas y poner un espacio vacío en ellas lo cual hasta cierto punto resulta fácil de ver que funciona pero nos toparemos con un efecto visual desagradable, cuando el tamaño de la serpiente sea muy grande notaremos un pardeo desagradable pues estaremos borrando toda la serpiente y después pintándola lo cual no es ni por mucho necesario, pues bastaría con borrar sólo “la cola de la serpiente” y es cuando debemos preguntarnos ¿en qué lugar de la matriz se encuentran dichas coordenadas?

Esta no es una pregunta trivial pues el lugar donde estén dichas coordenadas depende del orden en que sean llamadas estas 3 últimas funciones.

Si llamamos a estas 3 funciones dentro da la rutina del juego con el siguiente orden:

borrar_cuerpo();
guardar_posicion();
dibujar_cuerpo();

Veremos que el incremento de la variable “n” se da después de haber borrado el cuerpo pues este incremento se da dentro de la función guardar_posicion. Ahora cuando se empieza a sobrescribir la matriz cuando se hace n = 0 ya habíamos visto que la cola de la serpiente esta en los espacios cuerpo[1][0] y cuerpo[1][1] es decir cuerpo[n+1][0] y cuerpo[n+1][0] pero si revisamos la función guardar_posicion tenemos que al final hacemos n++ por lo que ahora n es el indice buscado por tanto la función borrar_cuerpo podría quedar simplemente de esta forma:

void serpiente::borrar_cuerpo() const{
     gotoxy(cuerpo[n][0],cuerpo[n][1]); printf(" ");
}
Nota:

Ahora la definición de la función teclear (en esta función se controlan los eventos del teclado).

Nosotros podemos definir dependiendo del valor que tenga la variable dirección mover la serpiente para arriba, abajo o a los lados, entonces haremos el siguiente convenio:

  • dir = 1 se moverá hacia arriba
  • dir = 2 se moverá hacia abajo
  • dir = 3 se moverá a la derecha
  • dir = 4 se moverá a la izquierda

Ahora, en el juego no se puede mover hacia abajo si la serpiente iba hacia arriba de igual forma no puede ir a la derecha si esta iba a la izquierda, solo se pueden hacer movientos en escuadra por lo que habría que poner una condición para que se realice el cambio de dirección.

Una manera de hacer esto es la siguiente:

void serpiente::teclear(){
     if(kbhit()){
            tecla = getch();
            switch(tecla){
               case ARRIBA:    if(dir != 2) dir = 1; break;
               case ABAJO:     if(dir != 1) dir = 2; break;
               case DERECHA:   if(dir != 4) dir = 3; break;
               case IZQUIERDA: if(dir != 3) dir = 4; break;
            }

     }
}

La función pide verificar si se ha presionado una tecla con la condición kbhit() dentro del if(), en dado caso que sea así le asignamos el valor de la tecla presionada a la variable tecla (tecla = getch();) y en base a su valor se ejecutara alguno de los 4 casos: ARRIBA, ABAJO, DERECHA O IZQUIERDA.

Pero estos 4 valores no han sido definidos por lo que se debe poner lo siguiente en la cabecera del programa (debajo de las librerías incluidas):

#define ARRIBA     72      // NUMEROS ASOCIADOS A LAS FLECHAS DEL TECLADO
#define IZQUIERDA  75
#define DERECHA    77
#define ABAJO      80
#define ESC        27

Los valores que se le asignan a cada palabra son los valores del código ascii asociados a las flechas del teclado.

Después de tener el valor de la dirección procedemos a dar los cambios a las coordenadas de la cabeza según sea su valor.

void serpiente::cambiar_pos(){
     if(dir == 3) x++;
     else if(dir == 1) y--;
     else if(dir == 4) x--;
     else if(dir == 2) y++;
}
Nota:

Recuerda que las coordenadas Y están invertidas por lo que para ir hacia arriba se debe hacer un decremento en la coordenada Y.

Después de esto hagamos la función que nos compara las coordenadas de la serpiente y la comida, les adelanto que si se cumplen las condiciones deberemos hacer cambios a la variable velocidad y a los puntos pero esa parte la omitiremos por ahora y lo implementaremos cuando tengamos un prototipo de la rutina del juego.

void serpiente::comparar_coordenadas(Comida &c){
     if(c.X() == x && c.Y() == y){

        tam++;
        c.AsignaCoordenadas((rand()%73)+4, (rand()%19)+4);
        c.pintar_comida();
     }
}

Cuando las coordenadas coinciden se incrementa el tamaño de la serpiente dejando que se guarde otro par ordenado en la matriz cuerpo, después asigna nuevas coordenadas al azar a la comida y por ultimo se pinta la comida en su nueva posición.

Podemos probar lo anterior haciendo lo siguiente dentro da la función main():

int main(){
    srand(time(NULL));
    OcultaCursor();

    pintar();
    serpiente A(5,10);
    Comida C ((rand()%73)+4 , (rand()%19)+4);
    C.pintar_comida();
    do{
       A.comparar_coordenadas(C);
       A.borrar_cuerpo();
       A.guardar_posicion();
       A.dibujar_cuerpo();
      Sleep(A.velocidad); // control del tiempo de espara para cada iteracion
       A.teclear();
       A.teclear();
       A.cambiar_pos();

    }while(A.tecla != ESC );// FIN DEL WHILE

    return 0;
}

La rutina del juego esta encerrada en el bucle do-while y este se dejara de efectuar solo cuando se presione la tecla escape pero también podemos poner como condición la función game_over pues esta es de tipo bool por lo que podríamos terminar el juego determinando los choques con los limites y su propio cuerpo dentro de esta función.

La función Sleep(int_x) da un retraso de _x milisegundos, en este caso se puso Sleep(A.velocidad); entonces si pudiéramos hacer un decremento a la variable velocidad cuando se tenga un determinado numero de puntos el tiempo de espera para cada iteración del bucle seria menor lo que haría que la velocidad del juego incrementara.

Hagamos la función game_over

Hay que comparar las coordenadas de la cabeza con las de los límites del escenario y revisar si la cabeza ha chocado con su propio cuerpo, para esto ultimo cabe aclarar que dentro de la rutina del juego las coordenadas x e y cambian al llamar a la función A.cambiar_pos(); y estas nuevas coordenadas sólo se guardaran en alguna posición de la matriz cuerpo para la otra iteración cuando se llama a la función A.guardar_posicion(); por lo que tenemos la seguridad de que al final del bucle ninguno de los pares ordenados guardados en la matriz cuerpo coinciden con las coordenadas x e y por lo que esto solo será cierto si en verdad existe un solapamiento entre la cabeza y el cuerpo.

Una posible definición podría ser la siguiente:

bool serpiente::game_over(){
     if(y == 3 || y == 23 || x == 2 || x == 77) return true;  // Choque con las paredes

     for(int j = 0; j <tam; j++){                      // Choque con su cuerpo
        if( x == cuerpo[j][0] && y == cuerpo[j][1] )   // He modificado el bucle para incluir la posición 0
             return true;
    }
    return false;
}

Por lo que la rutina del juego debería estar sujeta a la condición !A.game_over.

  do{
       A.comparar_coordenadas(C);
       A.borrar_cuerpo();
       A.guardar_posicion();
       A.dibujar_cuerpo();
      Sleep(A.velocidad); // control del tiempo de espara para cada iteracion
       A.teclear();
       A.teclear();
       A.cambiar_pos();
   }while(A.tecla != ESC && !A.game_over() );// FIN DEL WHILE

Ahora faltaría hacer la función que nos haga el cambio en la variable velocidad y la que nos imprime los puntos en pantalla. La función para pintar los puntos deberá ser llamada antes de la rutina del juego para que se imprima por primera vez el marcador y después las llamadas a las funciones cambiar velocidad y pintar marcador solo serán llamadas dentro de la función miembro de la serpiente comparar_coordenadas cuando se cumpla que la serpiente ha pasado por encima de la comida pues no es necesario llamar a estas funciones constantemente en la rutina del juego.

void serpiente::cambiar_velocidad(){
     if(score == h*50){
         velocidad-=10;
          h++;
     }
}

Recordemos que el constructor nos inicializo la variable “h” en 1 por lo que la condición del if se cumplirá cuando score sea 50 después hacemos un decremento a la variable velocidad y hacemos h++ con lo que esta condición se volverá a cumplir cuando score sea 100 así pues el cambio de la velocidad se dará cuando score sea un múltiplo de 50.

Ahora la función para imprimir el marcador:

void serpiente::puntos() const{
     gotoxy(3,1); printf("Score %5d", score);
}
Nota:

IMPORTANTE: Como haremos uso de estas dos funciones dentro de la función comparar_coordenadas estas deben ponerse antes de la definición de comparar_coordenadas.

Por último se hacen las llamadas a estas dos funciones dentro de la funcion comparar_coordenadas de la suiente manera:

void serpiente::comparar_coordenadas(Comida &c){
     if(c.X() == x && c.Y() == y){
        score+= 10;
        tam++;
        cambiar_velocidad();
        c.AsignaCoordenadas((rand()%73)+4, (rand()%19)+4);
        c.pintar_comida();
        puntos();    // Mostramos contador actualizado.
     }
}

Por ultimo llamamos a la función para pintar el marcador antes de la rutina del juego:

int main(){
    srand(time(NULL));

    OcultaCursor();

    pintar();
    serpiente A(5,10);
    Comida C ((rand()%73)+4 , (rand()%19)+4);
    C.pintar_comida();
    do{
       A.comparar_coordenadas(C);
       A.borrar_cuerpo();
       A.guardar_posicion();
       A.dibujar_cuerpo();

       Sleep(A.velocidad); // control del tiempo de espara para cada iteracion
       A.teclear();
       A.teclear();
       A.cambiar_pos();

    }while(A.tecla != ESC && !A.game_over());// FIN DEL WHILE

    return 0;
}