Juego de batalla naval con Arduino, LCD y comunicación serial

Introducción

No hace mucho me fue encargado un pequeño proyecto en donde tenía que simular el juego de una batalla naval usando un Arduino, dos módulos USB a TTL, algunos leds controlados con un 74hc595 y una LCD con el módulo I2C.

Trataré de ser lo más específico posible y no omitir ningún detalle.

Recuerda que todo esto es modificable, y con un poco de tiempo puedes adaptarlo a muchos otros escenarios. Por ejemplo, puede que no necesites los leds, o que en lugar de USB a TTL utilices Bluetooth, etcétera. Todo queda en ti.

Nota: también he creado el juego de batalla naval en Python.

Funcionamiento

Los barcos los ubicaría el jugador 1 (con coordenadas) e irían apareciendo sobre la LCD. Cada que se ubicaba uno de estos, se encendía un led indicando que el barco “estaba vivo”. Después, el jugador 2 trataba de darles sin ver la pantalla, usando al igual que el jugador 1, la comunicación serial.

Si le daba a un barco, el led que indicaba su estado se apagaba. Pero por ejemplo, para los que ocupan muchos espacios (es decir, los barcos grandes como submarinos, portaaviones, etcétera) se les tenía que destruir por completo para que su led se apagase.

El usuario 2 tenía 18 intentos para derribar todos los barcos. Ganaba si los desaparecía antes de que sus intentos se acabasen.

Adicional a esto, un buzzer sonaba indicando éxito o error en cada disparo. Y dichos disparos se marcaban en la LCD.

Materiales

  • Arduino UNO, o cualquier equivalente. (Lo he probado sólo con MEGA y UNO)
  • Pantalla LCD de 20 x 4 (o de 16 x 2) – para dibujar el escenario
  • Módulo I2C para LCD – para ahorrar cables
  • 7 leds – para el estado de los barcos
  • 7 resistencias –  para dichos leds
  • 1 Circuito integrado 74hc595 – para controlar los leds
  • Un buzzer – para indicar error o éxito
  • 2 módulos USB a TTL – uno a la pc del jugador 1. El otro a la del jugador 2

Requisitos

Tienes que saber la dirección del módulo I2C para la LCD, y también debes tener instalada la librería. Para ello, y para no hacer largo este post, separé esto en otros tutoriales:

Una vez que hayas leído eso, podemos continuar.

Paso 1: El escenario

El juego en sí no es complicado. Básicamente es un arreglo en donde estaremos cambiando los valores. Me decidí por un arreglo de tipo char, porque se me hace ligero y a su vez perfecto para los usos que le daremos.

Para comenzar vamos a definir la altura y anchura (o como se diga) del escenario; es decir, del arreglo, que debe medir lo mismo que nuestra LCD.

Si nuestra LCD es de 16×2, el arreglo será de 16×2. Si es de 20×4, el arreglo será de 20 x 4.

De todas maneras, haremos modificables las medidas. Así, primero podemos probar con una LCD pequeña, la de 16×2, que es la más popular. Y después, cambiar las constantes y conectar una de 20 x 4 u otras medidas.

Pero dejemos de hablar y veamos el código del escenario y las constantes que usaremos para pintar el arreglo, ya que los valores que habrán dentro de éste serán unos, y los que mostraremos al usuario final en la LCD serán otros:

#include <LiquidCrystal_I2C.h>


#define DISPARO_NO_ACERTADO '*'
#define DISPARO_ACERTADO '_'
#define AGUA ' '
#define FRAGATA_1 'A'
#define FRAGATA_2 'B'
#define DESTRUCTOR_1 'C'
#define DESTRUCTOR_2 'D'
#define ACORAZADO_1 'E'
#define SUBMARINO_1 'F'
#define PORTA_AVIONES_1 'G'
#define BARCO_IMPRIMIBLE '#'
#define DEBERIA_IMPRIMIR_LETRAS false


#define DIRECCION_LCD 0x3F //Recuerda cambiar esta dirección, para obtener la tuya visita https://parzibyte.me/blog/2018/02/02/obtener-direccion-modulo-i2c-lcd-arduino/
#define ALTURA_LCD 2
#define ANCHURA_LCD 16

LiquidCrystal_I2C lcd(DIRECCION_LCD, ANCHURA_LCD, ALTURA_LCD);
char escenario[ANCHURA_LCD][ALTURA_LCD];

/**
  Llena el arreglo de agua, pues al inicio no hay nada más que el océano o mar, o lo que sea;
  es decir, le pone espacios vacíos.
  Revisar la constante AGUA para entender mejor.
*/
void vaciarEscenario() {
  for (int x = 0; x < ANCHURA_LCD; x++)
    for (int y = 0; y < ALTURA_LCD; y++)
      escenario[x][y] = AGUA;
}


/*
  Encapsular las rutinas de la LCD en un método.
  Simplemente iniciamos, la encendemos y la limpiamos
*/
void prepararLcd() {
  lcd.init();
  lcd.backlight();
  lcd.clear();
}


/**
  El método que se encarga de convertir el arreglo en datos
  visibles al usuario. Por favor, leer esto:
    La constante DEBERIA_IMPRIMIR_LETRAS es para, como su nombre lo dice,
    mostrar o no letras en la LCD. ¿Por qué? como se ve, cada barco
    está representado por una constante cuyo valor es una A, B, C, etcétera.

    Pero esas letras están pensadas para los programadores, es decir, para nosotros.
    Así podremos probar mejor la ubicación y la definición de cada barco.

    Entonces, si la constante está en true, se imprimirán las letras verdaderas, algo "feo"
    para el usuario final pero útil para nosotros.


    De esta forma, se mostrarán letras en la LCD. Pero si la ponemos en false, se mostrarán #
    o la constante que definamos en BARCO_IMPRIMIBLE porque al usuario final sólo le importa saber
    que hay un barco ahí, independientemente de su tipo.

    En cambio, a nosotros no, ya que dentro del código tenemos que saber a cuál barco le han disparado y
    todas esas cosas.


    TL;DR
    Si estás probando, establece DEBERIA_IMPRIMIR_LETRAS en true
    Si ya probaste todo y el juego funciona, establece DEBERIA_IMPRIMIR_LETRAS en false

*/
void dibujarEscenario() {
  lcd.clear();
  for (int x = 0; x < ANCHURA_LCD; x++) {
    for (int y = 0; y < ALTURA_LCD; y++) {
      lcd.setCursor(x, y);
      /*
        Ubicar el cursor de la LCD en la posición del arreglo

        Comparar el valor de cada barco y dependiendo de ello imprimir el carácter.
        Por ejemplo, si es agua entonces no imprime nada.
        En cambio, si es un barco imprime el barco

        También imprime los disparos acertados o no acertados, cosa que veremos más
        adelante
      */
      if (DEBERIA_IMPRIMIR_LETRAS) {
        switch (escenario[x][y]) {
          case FRAGATA_1:
            lcd.print(FRAGATA_1);
            break;
          case FRAGATA_2:
            lcd.print(FRAGATA_2);
            break;
          case DESTRUCTOR_1:
            lcd.print(DESTRUCTOR_1);
            break;
          case DESTRUCTOR_2:
            lcd.print(DESTRUCTOR_2);
            break;
          case ACORAZADO_1:
            lcd.print(ACORAZADO_1);
            break;
          case SUBMARINO_1:
            lcd.print(SUBMARINO_1);
            break;
          case PORTA_AVIONES_1:
            lcd.print(PORTA_AVIONES_1);
            break;
          case AGUA:
            lcd.print(AGUA);
            break;
          case DISPARO_NO_ACERTADO:
          case DISPARO_ACERTADO:
            lcd.print(DISPARO_NO_ACERTADO);
            break;
        }
      } else {
        switch (escenario[x][y]) {
          case FRAGATA_1:
          case FRAGATA_2:
          case DESTRUCTOR_1:
          case DESTRUCTOR_2:
          case ACORAZADO_1:
          case SUBMARINO_1:
          case PORTA_AVIONES_1:
            lcd.print(BARCO_IMPRIMIBLE);
            break;
          case AGUA:
            lcd.print(AGUA);
            break;
          case DISPARO_NO_ACERTADO:
          case DISPARO_ACERTADO:
            lcd.print(DISPARO_NO_ACERTADO);
            break;
        }
      }
    }
  }
}

void setup() {
  prepararLcd();
  vaciarEscenario();
  dibujarEscenario();
}

void loop() {
  /*
  Nada por aquí, pues el juego se ejecuta una vez y comienza de nuevo solamente al reiniciar el Arduino. 
  
  Se siente bien no hacer nada en el loop
  
  */
}

Carguemos ese código a nuestra tarjeta, conectando a esta la LCD a través del módulo I2C. Veremos algo así:

¿Y eso es todo? claro que sí, no se ve a simple vista, pero el escenario ya está pintado. No se nota nada, porque declaramos la constante AGUA en un espacio en blanco.

Pero hagamos un experimento, cambiemos la constante AGUA y en lugar de ese espacio pongamos un punto (.), compilemos y veamos lo que sucede:

 Si el lector no se ha dado cuenta, esto sirve de muchas cosas. En primer lugar ya vimos que gracias a las constantes podemos cambiar la forma en la que el usuario observa el juego.

Por otro lado, se ve la utilidad de la constante DEBERIA_IMPRIMIR_LETRAS, pues si está en true nos mostrará detalladamente el escenario, no sólo cosas transparentes.

Muy bien, con esto ya tenemos una pequeña parte. Ahora veremos cómo modificar ese arreglo, parsear coordenadas, encender leds, apagarlos, y finalmente cómo llamar a estos métodos escuchando el puerto Serial.

Paso 2: la ubicación de los barcos | coordenadas

Como lo dije arriba, haremos que esto sea flexible. Así que crearemos funciones que sin importar de dónde vengan los datos, funcionarán.

Para ello, declararemos una función para poner determinado barco en determinada coordenada. Como estamos trabajando realmente con un arreglo, las coordenadas son índices o posiciones del mismo. Y como estamos pintando exactamente ese arreglo en la LCD, sólo tenemos que actualizar el dato y dibujar. ¿Inteligente, no?

Pedir coordenadas de barcos

Comenzaremos pidiendo la ubicación de cada barco. No me gusta mucho esta parte del código, me habría gustado hacerla un poco más independiente del número de barcos.

Recordemos que el usuario introducirá las coordenadas a través de la comunicación serial, ya sea utilizando el Monitor serial que el IDE de Arduino provee, u otro.

Como dijimos que se utilizarán 2 comunicaciones seriales, tenemos que escuchar a una u otra. Escuchamos entonces a la del jugador uno. Mandamos un mensaje de bienvenida y comenzamos a pedir las ubicaciones:

void pedirUbicacionDeBarcos() {
  serialJugadorUno.listen();
  serialJugadorUno.println("Bienvenido, jugador 1. Es hora de ubicar tus barcos");
  String coordenadas;
  int barcos = 7,
      barcosCompletados = 0;
  do {
    String nombreBarco = "";
    switch (barcosCompletados) {
      case 0:
        nombreBarco = "Fragata 1";
        break;
      case 1:
        nombreBarco = "Fragata 2";
        break;
      case 2:
        nombreBarco = "Destructor 1";
        break;
      case 3:
        nombreBarco = "Destructor 2";
        break;
      case 4:
        nombreBarco = "Acorazado";
        break;
      case 5:
        nombreBarco = "Submarino";
        break;
      case 6:
        nombreBarco = "Porta aviones";
        break;
    }
    serialJugadorUno.println("Introduce las coordenadas de " + nombreBarco + "");
    while (!serialJugadorUno.available());
    coordenadas = serialJugadorUno.readString();
    int x, y;
    if (esCoordenadaValida(coordenadas, x, y)) {
      switch (barcosCompletados) {
        case 0:
          //Fragata 1
          if (intentarDibujarBarco(x, y, FRAGATA_1, LONGITUD_FRAGATAS)) {
            dibujarEscenario();
            barcosCompletados++;
            encenderLedDe(FRAGATA_1);
          } else {
            serialJugadorUno.println("Coordenadas correctas, pero ya existe un barco en esa posición. Intenta de nuevo");
          }
          break;
        case 1:
          //Fragata 2
          if (intentarDibujarBarco(x, y, FRAGATA_2, LONGITUD_FRAGATAS)) {
            dibujarEscenario();
            barcosCompletados++;
            encenderLedDe(FRAGATA_2);
          } else {
            serialJugadorUno.println("Coordenadas correctas, pero ya existe un barco en esa posición. Intenta de nuevo");
          }
          break;
        case 2:
          //Destructor 1
          if (intentarDibujarBarco(x, y, DESTRUCTOR_1, LONGITUD_DESTRUCTORES)) {
            dibujarEscenario();
            barcosCompletados++;
            encenderLedDe(DESTRUCTOR_1);
          } else {
            serialJugadorUno.println("Coordenadas correctas, pero ya existe un barco en esa posición. Intenta de nuevo");
          }
          break;
        case 3:
          //Destructor 2
          if (intentarDibujarBarco(x, y, DESTRUCTOR_2, LONGITUD_DESTRUCTORES)) {
            dibujarEscenario();
            barcosCompletados++;
            encenderLedDe(DESTRUCTOR_2);
          } else {
            serialJugadorUno.println("Coordenadas correctas, pero ya existe un barco en esa posición. Intenta de nuevo");
          }
          break;
        case 4:
          //Acorazado 1
          if (intentarDibujarBarco(x, y, ACORAZADO_1, LONGITUD_ACORAZADOS)) {
            dibujarEscenario();
            barcosCompletados++;
            encenderLedDe(ACORAZADO_1);
          } else {
            serialJugadorUno.println("Coordenadas correctas, pero ya existe un barco en esa posición. Intenta de nuevo");
          }
          break;
        case 5:
          //Submarino 1
          if (intentarDibujarBarco(x, y, SUBMARINO_1, LONGITUD_SUBMARINOS)) {
            dibujarEscenario();
            barcosCompletados++;
            encenderLedDe(SUBMARINO_1);
          } else {
            serialJugadorUno.println("Coordenadas correctas, pero ya existe un barco en esa posición. Intenta de nuevo");
          }
          break;
        case 6:
          //Porta aviones 1
          if (intentarDibujarBarco(x, y, PORTA_AVIONES_1, LONGITUD_PORTA_AVIONES)) {
            dibujarEscenario();
            barcosCompletados++;
            encenderLedDe(PORTA_AVIONES_1);
          } else {
            serialJugadorUno.println("Coordenadas correctas, pero ya existe un barco en esa posición. Intenta de nuevo");
          }
          break;
      }
    } else {
      serialJugadorUno.println("Coordenadas incorrectas o fuera de rango. Recuerda que debes introducirlas en el formato x,y (con la coma incluida)");
    }
    coordenadas = "";
  } while (barcosCompletados < barcos);
  serialJugadorUno.println("Se ha terminado tu tiempo. Turno del jugador 2");
}

Es importante notar la línea que dice:

while (!serialJugadorUno.available());

Sin ella, no habría podido esperar a que el usuario introdujera las coordenadas. Lo que hace ese fragmento es pausar el programa indefinidamente, hasta que haya algo que leer en el Serial. También están los métodos encenderLedDeesCoordenadaValida e intentarDibujarBarco, que veremos más abajo.

Comprobar coordenadas válidas

De este método me siento muy orgulloso, me gustó la manera en la que lo hice. Primero verifica si existe una coma en la cadena. En caso de que no, de una vez regresamos falso.

En caso de que sí, la cortamos. X sería lo que hay desde la posición 0 hasta en donde hayamos encontrado la coma. Y sería desde donde encontramos la coma, hasta el final de la cadena.

Acabo de darme cuenta de que cuando lo hice (que por cuestiones de tiempo no pude arreglar, jaja) había un error al parsear las coordenadas si ponía length – 1. Pero al poner length – 2, todo iba bien. Creo (y sólo creo) que es porque hay un salto de línea por ahí. Y si no, igual no importa.

Bueno bueno, luego comprobamos si la coordenada es un número, para ello utilicé una función que encontré en internet.

Finalmente, la última prueba es que las coordenadas estén en el rango de la LCD. Es decir, que X no sea menor que 0 ni mayor que la longitud de la pantalla. Y para Y lo mismo. ¿Ya vieron cómo las constantes que declaramos nos siguen ayudando?

boolean esCoordenadaValida(String coordenadas, int &x, int &y) {
  int indice = coordenadas.indexOf(",");
  if (indice != -1) {
    String supuestoX = coordenadas.substring(0, indice),
           supuestoY = coordenadas.substring(indice + 1, coordenadas.length() - 2);
    if (isNumeric(supuestoX) && isNumeric(supuestoY)) {
      x = supuestoX.toInt(), y = supuestoY.toInt();
      return x >= 0 && y >= 0 && x < ANCHURA_LCD && y < ALTURA_LCD;
    }
  }
  return false;
}

Dibujar un barco

Este método también fue difícil de hacer. En las instrucciones no especificaba cómo debería ir el barco. Veamos un submarino, creo que ocupa 3 espacios. ¿Cómo los iba a ocupar? ¿Se iba a extender hacia la derecha, hacia arriba, abajo o a la izquierda?

Para eliminar todas estas dudas, hice una función que primero dibujara el barco de derecha a izquierda. Luego hacia abajo, izquierda y finalmente hacia arriba. Si no podía dibujarlo, entonces regresaba false, pues ya estaban ocupadas esas posiciones o el barco no cabía.

También es importante notar el método estaVacioEn que como su nombre lo dice, te indica si no has ocupado una coordenada.

Para dibujar:

boolean intentarDibujarBarco(int x, int y, char simbolo, int longitud) {
  boolean puedeDibujar = true;
  //A la derecha
  for (int d = 0; d < longitud; d++) {
    puedeDibujar = puedeDibujar && estaVacioEn(x + d, y);
  }
  if (puedeDibujar) {
    for (int d = 0; d < longitud; d++) {
      escenario[x + d][y] = simbolo;
    }
    return true;
  }

  //Hacia abajo
  puedeDibujar = true;
  for (int d = 0; d < longitud; d++) {
    puedeDibujar = puedeDibujar && estaVacioEn(x, y + d);
  }
  if (puedeDibujar) {
    for (int d = 0; d < longitud; d++) {
      escenario[x][y + d] = simbolo;
    }
    return true;
  }

  //Hacia la izquierda
  puedeDibujar = true;
  for (int d = 0; d < longitud; d++) {
    puedeDibujar = puedeDibujar && estaVacioEn(x - d, y);
  }
  if (puedeDibujar) {
    for (int d = 0; d < longitud; d++) {
      escenario[x - d][y] = simbolo;
    }
    return true;
  }

  //Hacia arriba
  puedeDibujar = true;
  for (int d = 0; d < longitud; d++) {
    puedeDibujar = puedeDibujar && estaVacioEn(x, y - d);
  }
  if (puedeDibujar) {
    for (int d = 0; d < longitud; d++) {
      escenario[x][y - d] = simbolo;
    }
    return true;
  }
  return false;
}

Y el método para ver si está vacío:

boolean estaVacioEn(int x, int y) {
  return x >= 0 && x < ANCHURA_LCD && y >= 0 && y < ALTURA_LCD && escenario[x][y] == AGUA;
}

Espero que el lector se dé cuenta de que las constantes siguen siendo de gran ayuda. Utilizamos de nuevo las que indican las medidas de la pantalla, y también la constante AGUA.

Paso 3: Encender y apagar LEDS

¿Ya dije difícil? bueno, este método también es difícil.

Encender un LED es una de las cosas más simples en el mundo de Arduino, de hecho es casi como el Hola mundo. La pequeña diferencia es que en este caso se tenía que utilizar un circuito integrado 74HC595, del cual ya he hablado.

Lo que tenía que estar modificando era un byte. Sí, un byte, de esos que tienen 8 bits. Y cada que encendía o apagaba un led, tenía que recorrerlo y utilizar bitSet para cambiar el valor de 0 a 1. Luego, llamaba a refrescarLeds para que por medio de shiftOut se escribiera el valor en el integrado.

Para encender determinado led:

void encenderLedDe(char barco) {
  switch (barco) {
    case FRAGATA_1:
      bitSet(leds, 0);
      break;
    case FRAGATA_2:
      bitSet(leds, 1);
      break;
    case DESTRUCTOR_1:
      bitSet(leds, 2);
      break;
    case DESTRUCTOR_2:
      bitSet(leds, 3);
      break;
    case ACORAZADO_1:
      bitSet(leds, 4);
      break;
    case SUBMARINO_1:
      bitSet(leds, 5);
      break;
    case PORTA_AVIONES_1:
      bitSet(leds, 6);
      break;
  }
  refrescarLeds();
}

Para apagarlo:

void apagarLedDe(char barco) {
  switch (barco) {
    case FRAGATA_1:
      bitClear(leds, 0);
      break;
    case FRAGATA_2:
      bitClear(leds, 1);
      break;
    case DESTRUCTOR_1:
      bitClear(leds, 2);
      break;
    case DESTRUCTOR_2:
      bitClear(leds, 3);
      break;
    case ACORAZADO_1:
      bitClear(leds, 4);
      break;
    case SUBMARINO_1:
      bitClear(leds, 5);
      break;
    case PORTA_AVIONES_1:
      bitClear(leds, 6);
      break;
  }
  refrescarLeds();
}

Y el que refresca los leds es:

void refrescarLeds() {
  digitalWrite(PIN_LATCH, LOW);
  shiftOut(PIN_DATA, PIN_CLOCK, MSBFIRST, leds);
  digitalWrite(PIN_LATCH, HIGH);
}

Las constantes nos siguen ayudando. Y ya con esto se acaba el turno del jugador 1.

Paso 5: Alisten, Apunten, ¡Fuego!

Ahora va el jugador 2. Escuchamos la conexión serial 2, le pediremos igualmente una coordenada, reutilizando los métodos que vimos más arriba para validarlas y todo eso.

Comenzamos imprimiendo los intentos restantes. Dichos intentos son el resultado de  restar a una constante (que podemos aumentar o disminuir según sea el caso) el valor de una variable. Dicha variable son los intentos que el usuario ya ha hecho.

Si se agotan los intentos, pierde. Si no, pues no.

void turnoDelJugadorDos() {

  String coordenadas;
  /*
    Escuchar y dar bienvenida
  */
  serialJugadorDos.listen();
  serialJugadorDos.println("Bienvenido, jugador 2");

  int barcosDestruidos = 0;
  boolean intentosAgotados = false, todosLosBarcosDestruidos = false;
  do {
    serialJugadorDos.println("Te quedan " + String(INTENTOS_MAXIMOS - intentosJugadorDos) + " intentos. Ingresa las coordenadas para realizar el ataque: ");

    while (!serialJugadorDos.available());
    coordenadas = serialJugadorDos.readString();
    int x, y;
    boolean deberiaRestarIntento = true;
    if (esCoordenadaValida(coordenadas, x, y)) {
      char objetivo = dispararYObtenerObjetivo(x, y);
      switch (objetivo) {
        case AGUA:
          serialJugadorDos.println("Disparo no acertado. Le diste al agua.");
          marcarDisparoNoAcertadoEn(x, y);
          indicarDisparoErroneo();
          break;
        case FRAGATA_1:
        case FRAGATA_2:
        case DESTRUCTOR_1:
        case DESTRUCTOR_2:
        case ACORAZADO_1:
        case SUBMARINO_1:
        case PORTA_AVIONES_1:
          serialJugadorDos.println("Disparo acertado!");
          marcarDisparoAcertadoEn(x, y);
          indicarDisparoAcertado();
          break;
        case DISPARO_ACERTADO:
        case DISPARO_NO_ACERTADO:
          deberiaRestarIntento = false;
          serialJugadorDos.println("Ya habias disparado a esas coordenadas. Intenta en otro lugar");
          break;
      }
      if (!hayMasInstanciasDe(objetivo)) {
        apagarLedDe(objetivo);
        barcosDestruidos++;
        //Right here, restar los barcos
      }
    } else {
      serialJugadorDos.println("Coordenadas invalidas o fuera de rango");
      deberiaRestarIntento = false;
    }
    if (deberiaRestarIntento) intentosJugadorDos++;
    dibujarEscenario();
    intentosAgotados = intentosJugadorDos >= INTENTOS_MAXIMOS;
    todosLosBarcosDestruidos = barcosDestruidos >= 7;
    if (intentosAgotados || todosLosBarcosDestruidos) break;
  } while (true);
  if (todosLosBarcosDestruidos) {
    jugadorDosGana();
  } else if (intentosAgotados) {
    jugadorDosPierde();
  }
}

Vemos que hay una variable booleana para restar o no el intento. Esto es porque, si el usuario pone una coordenada mal, no se le resta intento. Tampoco si le dispara a un lugar en donde había disparado antes.

Encerramos todo en un ciclo infinito, que será roto únicamente cuando se acaben los intentos o se destruyan todos los barcos. Veamos ahora los métodos que no he explicado.

Comprobar a qué cosa se disparó

Para esto utilizamos el método disparar y obtener objetivo. El método es simple y explicativo. Simplemente te dice a qué lugar disparaste, y dependiendo de ello puedes marcar un disparo acertado, o no hacerlo. También ayuda a saber si se tiene que restar o no un intento.

char dispararYObtenerObjetivo(int x, int y) {
  return escenario[x][y];
}

Por cierto, este método es inseguro, ya que no comprueba que las coordenadas son válidas por sí mismo, sino que confía en que alguien más lo haya hecho por él. Y claro, lo hicimos nosotros antes de llamarlo.

Marcar e indicar disparos acertados o erróneos

Ya casi olvidaba que tenemos un buzzer que indica si acertamos o no. Para marcar el disparo, simplemente ponemos el valor de la constante en esa posición del arreglo. Igual por si fue erróneo.

Y hay otros dos métodos que sirven para hacer sonar al buzzer. Aquí el código de todo:

void encenderBuzzer() {
  digitalWrite(BUZZER, HIGH);
}

void apagarBuzzer() {
  digitalWrite(BUZZER, LOW);
}
void marcarDisparoNoAcertadoEn(int x, int y) {
  escenario[x][y] = DISPARO_NO_ACERTADO;
}
void marcarDisparoAcertadoEn(int x, int y) {
  escenario[x][y] = DISPARO_ACERTADO;
}
void indicarDisparoAcertado() {
  encenderBuzzer();
  delay(DURACION_BUZZER_DISPARO_ACERTADO);
  apagarBuzzer();
}
void indicarDisparoErroneo() {
  for (int x = 0; x < 5; x++) {
    encenderBuzzer();
    delay((DURACION_BUZZER_DISPARO_NO_ACERTADO / 2) / 5);
    apagarBuzzer();
    delay((DURACION_BUZZER_DISPARO_NO_ACERTADO / 2) / 5);
  }
}

Comprobar si quedan partes de un barco

Tomando de nuevo como ejemplo al submarino, vemos que ocupa muchos espacios. Si le damos con un disparo, se hundirá una parte de él, pero no lo hemos vencido. Es decir, si ocupa 3 espacios y le damos a uno, quedarán 2 en donde seguirá vivo.

¿Y qué significa esto? que no podremos apagar el LED hasta que se haya hundido completamente. Y tampoco contará como barco derribado si no lo hundimos completamente.

Para ello, existe el método hayMasInstanciasDe, que como su nombre lo dice, comprueba si quedan todavía algunos (aunque sea “heridos”) espacios ocupados por determinado barco. Aquí el código:

boolean hayMasInstanciasDe(char barco) {
  /*
    Buscar si hay más "instancias" del barco
    al que le han disparado. Es decir, si el barco
    atacado es, por ejemplo, FRAGATA_1 (que ocupa un espacio),
    su espacio será sustituido por un disparo, por lo que no
    habrá más FRAGATA_1 en el array

    En el caso del submarino, que mide 3, si le dan a una parte
    del mismo sólo habrán eliminado 1 espacio, pero en el
    arreglo quedarán 2 instancias del mismo, por lo que no se
    considerará eliminado
  */
  for (int e = 0; e < ANCHURA_LCD; e++) {
    for (int n = 0; n < ALTURA_LCD; n++) {
      if (escenario[e][n] == barco) return true;
    }
  }
  return false;
}

Indicando victoria o fracaso

Ya para terminar todo este código, dependiendo de cómo haya jugado el jugador indicamos la victoria o su fracaso. El código es muy simple, sólo imprime mensajes para ambos jugadores.

void jugadorDosPierde() {
  serialJugadorDos.println("Pierdes");
  serialJugadorUno.println("Ganas");
  encenderBuzzer();
  delay(2500);
  apagarBuzzer();
}
void jugadorDosGana() {
  serialJugadorDos.println("Ganas");
  serialJugadorUno.println("Pierdes");
  for (int x = 0; x < 10; x++) {
    encenderBuzzer();
    delay(125);
    apagarBuzzer();
    delay(125);
  }
}

Y el juego se queda así hasta que el Arduino se reinicia. Si quisiéramos que se repita una vez que pierde o gana, pondríamos lo que está en el setup dentro del loop.

Paso 6: código fuente completo

Para que no andes copiando y pegando parte por parte, aquí dejo el código que compone al Sketch. Recuerda que puede tener errores.

/*
  Simular juego de batalla naval en Arduino
  @date 9 de diciembre del 2017
  @author parzibyte
  @web https://www.parzibyte.me/blog
*/

#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <SoftwareSerial.h>


#define DIRECCION_LCD 0x3F
#define ALTURA_LCD 4
#define ANCHURA_LCD 20
#define PIN_CLOCK 2
#define PIN_LATCH 3
#define PIN_DATA 4
/*
  Número de espacios que
  ocupa cada barco
*/
#define LONGITUD_FRAGATAS 1
#define LONGITUD_DESTRUCTORES 2
#define LONGITUD_ACORAZADOS 3
#define LONGITUD_SUBMARINOS 3
#define LONGITUD_PORTA_AVIONES 4

/*
  Símbolos
*/
#define DISPARO_NO_ACERTADO '*'
#define DISPARO_ACERTADO '_'
#define AGUA ' '
#define FRAGATA_1 'A'
#define FRAGATA_2 'B'
#define DESTRUCTOR_1 'C'
#define DESTRUCTOR_2 'D'
#define ACORAZADO_1 'E'
#define SUBMARINO_1 'F'
#define PORTA_AVIONES_1 'G'
#define BARCO_IMPRIMIBLE '#'
#define DEBERIA_IMPRIMIR_LETRAS false

#define INTENTOS_MAXIMOS 18

/*
  Pines
*/
#define BUZZER 7

/*
  Duraciones
*/

#define DURACION_BUZZER_DISPARO_ACERTADO 200
#define DURACION_BUZZER_DISPARO_NO_ACERTADO 300

//Para interactuar con la LCD
LiquidCrystal_I2C lcd(DIRECCION_LCD, ANCHURA_LCD, ALTURA_LCD);

/*
  Los puertos seriales de
  cada jugador
*/
SoftwareSerial serialJugadorUno(10, 11);//TX, RX
SoftwareSerial serialJugadorDos(8, 9);

/*
  El arreglo que representa el escenario
  que será pintado en la pantalla
*/
char escenario[ANCHURA_LCD][ALTURA_LCD];


/*
  Algunas otras variables útiles
*/

int intentosJugadorDos = 0;
byte leds = B00000000; //Lo que será mandado al registro de corrimiento

/*
    Setup y loop
*/
void setup() {
  prepararLcd();
  establecerPines();
  serialJugadorUno.begin(9600);
  serialJugadorDos.begin(9600);
  vaciarEscenario();
  dibujarEscenario();
  refrescarLeds();
  /*
    Ciclo infinito. Esperar
    hasta que todos los barcos
    estén asignados
  */
  pedirUbicacionDeBarcos();

  /*
    Ahora es el turno del jugador 2
  */

  turnoDelJugadorDos();
  /*Fin*/
}

void loop() {
  /*Nada por aquí*/
}


/*
  Se encarga de poner
  agua en todo el escenario
*/
void vaciarEscenario() {
  for (int x = 0; x < ANCHURA_LCD; x++)
    for (int y = 0; y < ALTURA_LCD; y++)
      escenario[x][y] = AGUA;
}

void refrescarLeds() {
  digitalWrite(PIN_LATCH, LOW);
  shiftOut(PIN_DATA, PIN_CLOCK, MSBFIRST, leds);
  digitalWrite(PIN_LATCH, HIGH);
}

/*
  Volca o dibuja
  el arreglo en
  la LCD
*/
void dibujarEscenario() {
  lcd.clear();
  for (int x = 0; x < ANCHURA_LCD; x++) {
    for (int y = 0; y < ALTURA_LCD; y++) {
      lcd.setCursor(x, y);
      if (DEBERIA_IMPRIMIR_LETRAS) {
        switch (escenario[x][y]) {
          case FRAGATA_1:
            lcd.print(FRAGATA_1);
            break;
          case FRAGATA_2:
            lcd.print(FRAGATA_2);
            break;
          case DESTRUCTOR_1:
            lcd.print(DESTRUCTOR_1);
            break;
          case DESTRUCTOR_2:
            lcd.print(DESTRUCTOR_2);
            break;
          case ACORAZADO_1:
            lcd.print(ACORAZADO_1);
            break;
          case SUBMARINO_1:
            lcd.print(SUBMARINO_1);
            break;
          case PORTA_AVIONES_1:
            lcd.print(PORTA_AVIONES_1);
            break;
          case AGUA:
            lcd.print(AGUA);
            break;
          case DISPARO_NO_ACERTADO:
          case DISPARO_ACERTADO:
            lcd.print(DISPARO_NO_ACERTADO);
            break;
        }
      } else {
        switch (escenario[x][y]) {
          case FRAGATA_1:
          case FRAGATA_2:
          case DESTRUCTOR_1:
          case DESTRUCTOR_2:
          case ACORAZADO_1:
          case SUBMARINO_1:
          case PORTA_AVIONES_1:
            lcd.print(BARCO_IMPRIMIBLE);
            break;
          case AGUA:
            lcd.print(AGUA);
            break;
          case DISPARO_NO_ACERTADO:
          case DISPARO_ACERTADO:
            lcd.print(DISPARO_NO_ACERTADO);
            break;
        }
      }
    }
  }
}

void prepararLcd() {
  lcd.init();
  lcd.backlight();
  lcd.clear();
}


void turnoDelJugadorDos() {

  String coordenadas;
  /*
    Escuchar y dar bienvenida
  */
  serialJugadorDos.listen();
  serialJugadorDos.println("Bienvenido, jugador 2");

  int barcosDestruidos = 0;
  boolean intentosAgotados = false, todosLosBarcosDestruidos = false;
  do {
    serialJugadorDos.println("Te quedan " + String(INTENTOS_MAXIMOS - intentosJugadorDos) + " intentos. Ingresa las coordenadas para realizar el ataque: ");

    while (!serialJugadorDos.available());
    coordenadas = serialJugadorDos.readString();
    int x, y;
    boolean deberiaRestarIntento = true;
    if (esCoordenadaValida(coordenadas, x, y)) {
      char objetivo = dispararYObtenerObjetivo(x, y);
      switch (objetivo) {
        case AGUA:
          serialJugadorDos.println("Disparo no acertado. Le diste al agua.");
          marcarDisparoNoAcertadoEn(x, y);
          indicarDisparoErroneo();
          break;
        case FRAGATA_1:
        case FRAGATA_2:
        case DESTRUCTOR_1:
        case DESTRUCTOR_2:
        case ACORAZADO_1:
        case SUBMARINO_1:
        case PORTA_AVIONES_1:
          serialJugadorDos.println("Disparo acertado!");
          marcarDisparoAcertadoEn(x, y);
          indicarDisparoAcertado();
          break;
        case DISPARO_ACERTADO:
        case DISPARO_NO_ACERTADO:
          deberiaRestarIntento = false;
          serialJugadorDos.println("Ya habias disparado a esas coordenadas. Intenta en otro lugar");
          break;
      }
      if (!hayMasInstanciasDe(objetivo)) {
        apagarLedDe(objetivo);
        barcosDestruidos++;
        //Right here, restar los barcos
      }
    } else {
      serialJugadorDos.println("Coordenadas invalidas o fuera de rango");
      deberiaRestarIntento = false;
    }
    if (deberiaRestarIntento) intentosJugadorDos++;
    dibujarEscenario();
    intentosAgotados = intentosJugadorDos >= INTENTOS_MAXIMOS;
    todosLosBarcosDestruidos = barcosDestruidos >= 7;
    if (intentosAgotados || todosLosBarcosDestruidos) break;
  } while (true);
  if (todosLosBarcosDestruidos) {
    jugadorDosGana();
  } else if (intentosAgotados) {
    jugadorDosPierde();
  }
}
void jugadorDosPierde() {
  serialJugadorDos.println("Pierdes");
  serialJugadorUno.println("Ganas");
  encenderBuzzer();
  delay(2500);
  apagarBuzzer();
}
void jugadorDosGana() {
  serialJugadorDos.println("Ganas");
  serialJugadorUno.println("Pierdes");
  for (int x = 0; x < 10; x++) {
    encenderBuzzer();
    delay(125);
    apagarBuzzer();
    delay(125);
  }
}
void encenderLedDe(char barco) {
  switch (barco) {
    case FRAGATA_1:
      bitSet(leds, 0);
      break;
    case FRAGATA_2:
      bitSet(leds, 1);
      break;
    case DESTRUCTOR_1:
      bitSet(leds, 2);
      break;
    case DESTRUCTOR_2:
      bitSet(leds, 3);
      break;
    case ACORAZADO_1:
      bitSet(leds, 4);
      break;
    case SUBMARINO_1:
      bitSet(leds, 5);
      break;
    case PORTA_AVIONES_1:
      bitSet(leds, 6);
      break;
  }
  refrescarLeds();
}

void apagarLedDe(char barco) {
  switch (barco) {
    case FRAGATA_1:
      bitClear(leds, 0);
      break;
    case FRAGATA_2:
      bitClear(leds, 1);
      break;
    case DESTRUCTOR_1:
      bitClear(leds, 2);
      break;
    case DESTRUCTOR_2:
      bitClear(leds, 3);
      break;
    case ACORAZADO_1:
      bitClear(leds, 4);
      break;
    case SUBMARINO_1:
      bitClear(leds, 5);
      break;
    case PORTA_AVIONES_1:
      bitClear(leds, 6);
      break;
  }
  refrescarLeds();
}

boolean hayMasInstanciasDe(char barco) {
  /*
    Buscar si hay más "instancias" del barco
    al que le han disparado. Es decir, si el barco
    atacado es, por ejemplo, FRAGATA_1 (que ocupa un espacio),
    su espacio será sustituido por un disparo, por lo que no
    habrá más FRAGATA_1 en el array

    En el caso del submarino, que mide 3, si le dan a una parte
    del mismo sólo habrán eliminado 1 espacio, pero en el
    arreglo quedarán 2 instancias del mismo, por lo que no se
    considerará eliminado
  */
  for (int e = 0; e < ANCHURA_LCD; e++) {
    for (int n = 0; n < ALTURA_LCD; n++) {
      if (escenario[e][n] == barco) return true;
    }
  }
  return false;
}
void establecerPines() {
  pinMode(PIN_CLOCK, OUTPUT);
  pinMode(PIN_LATCH, OUTPUT);
  pinMode(PIN_DATA, OUTPUT);
  pinMode(BUZZER, OUTPUT);
}
void encenderBuzzer() {
  digitalWrite(BUZZER, HIGH);
}

void apagarBuzzer() {
  digitalWrite(BUZZER, LOW);
}
void marcarDisparoNoAcertadoEn(int x, int y) {
  escenario[x][y] = DISPARO_NO_ACERTADO;
}
void marcarDisparoAcertadoEn(int x, int y) {
  escenario[x][y] = DISPARO_ACERTADO;
}
void indicarDisparoAcertado() {
  encenderBuzzer();
  delay(DURACION_BUZZER_DISPARO_ACERTADO);
  apagarBuzzer();
}
void indicarDisparoErroneo() {
  for (int x = 0; x < 5; x++) {
    encenderBuzzer();
    delay((DURACION_BUZZER_DISPARO_NO_ACERTADO / 2) / 5);
    apagarBuzzer();
    delay((DURACION_BUZZER_DISPARO_NO_ACERTADO / 2) / 5);
  }
}
char dispararYObtenerObjetivo(int x, int y) {
  return escenario[x][y];
}
void escucharJugadorDos() {
  serialJugadorDos.listen();
}


boolean estaVacioEn(int x, int y) {
  return x >= 0 && x < ANCHURA_LCD && y >= 0 && y < ALTURA_LCD && escenario[x][y] == AGUA;
}

char queHayEn(int x, int y) {
  return escenario[x][y];
}

void pedirUbicacionDeBarcos() {
  serialJugadorUno.listen();
  serialJugadorUno.println("Bienvenido, jugador 1. Es hora de ubicar tus barcos");
  String coordenadas;
  int barcos = 7,
      barcosCompletados = 0;
  do {
    String nombreBarco = "";
    switch (barcosCompletados) {
      case 0:
        nombreBarco = "Fragata 1";
        break;
      case 1:
        nombreBarco = "Fragata 2";
        break;
      case 2:
        nombreBarco = "Destructor 1";
        break;
      case 3:
        nombreBarco = "Destructor 2";
        break;
      case 4:
        nombreBarco = "Acorazado";
        break;
      case 5:
        nombreBarco = "Submarino";
        break;
      case 6:
        nombreBarco = "Porta aviones";
        break;
    }
    serialJugadorUno.println("Introduce las coordenadas de " + nombreBarco + "");
    while (!serialJugadorUno.available());
    coordenadas = serialJugadorUno.readString();
    int x, y;
    if (esCoordenadaValida(coordenadas, x, y)) {
      switch (barcosCompletados) {
        case 0:
          //Fragata 1
          if (intentarDibujarBarco(x, y, FRAGATA_1, LONGITUD_FRAGATAS)) {
            dibujarEscenario();
            barcosCompletados++;
            encenderLedDe(FRAGATA_1);
          } else {
            serialJugadorUno.println("Coordenadas correctas, pero ya existe un barco en esa posición. Intenta de nuevo");
          }
          break;
        case 1:
          //Fragata 2
          if (intentarDibujarBarco(x, y, FRAGATA_2, LONGITUD_FRAGATAS)) {
            dibujarEscenario();
            barcosCompletados++;
            encenderLedDe(FRAGATA_2);
          } else {
            serialJugadorUno.println("Coordenadas correctas, pero ya existe un barco en esa posición. Intenta de nuevo");
          }
          break;
        case 2:
          //Destructor 1
          if (intentarDibujarBarco(x, y, DESTRUCTOR_1, LONGITUD_DESTRUCTORES)) {
            dibujarEscenario();
            barcosCompletados++;
            encenderLedDe(DESTRUCTOR_1);
          } else {
            serialJugadorUno.println("Coordenadas correctas, pero ya existe un barco en esa posición. Intenta de nuevo");
          }
          break;
        case 3:
          //Destructor 2
          if (intentarDibujarBarco(x, y, DESTRUCTOR_2, LONGITUD_DESTRUCTORES)) {
            dibujarEscenario();
            barcosCompletados++;
            encenderLedDe(DESTRUCTOR_2);
          } else {
            serialJugadorUno.println("Coordenadas correctas, pero ya existe un barco en esa posición. Intenta de nuevo");
          }
          break;
        case 4:
          //Acorazado 1
          if (intentarDibujarBarco(x, y, ACORAZADO_1, LONGITUD_ACORAZADOS)) {
            dibujarEscenario();
            barcosCompletados++;
            encenderLedDe(ACORAZADO_1);
          } else {
            serialJugadorUno.println("Coordenadas correctas, pero ya existe un barco en esa posición. Intenta de nuevo");
          }
          break;
        case 5:
          //Submarino 1
          if (intentarDibujarBarco(x, y, SUBMARINO_1, LONGITUD_SUBMARINOS)) {
            dibujarEscenario();
            barcosCompletados++;
            encenderLedDe(SUBMARINO_1);
          } else {
            serialJugadorUno.println("Coordenadas correctas, pero ya existe un barco en esa posición. Intenta de nuevo");
          }
          break;
        case 6:
          //Porta aviones 1
          if (intentarDibujarBarco(x, y, PORTA_AVIONES_1, LONGITUD_PORTA_AVIONES)) {
            dibujarEscenario();
            barcosCompletados++;
            encenderLedDe(PORTA_AVIONES_1);
          } else {
            serialJugadorUno.println("Coordenadas correctas, pero ya existe un barco en esa posición. Intenta de nuevo");
          }
          break;
      }
    } else {
      serialJugadorUno.println("Coordenadas incorrectas o fuera de rango. Recuerda que debes introducirlas en el formato x,y (con la coma incluida)");
    }
    coordenadas = "";
  } while (barcosCompletados < barcos);
  serialJugadorUno.println("Se ha terminado tu tiempo. Turno del jugador 2");
}
boolean intentarDibujarBarco(int x, int y, char simbolo, int longitud) {
  boolean puedeDibujar = true;
  //A la derecha
  for (int d = 0; d < longitud; d++) {
    puedeDibujar = puedeDibujar && estaVacioEn(x + d, y);
  }
  if (puedeDibujar) {
    for (int d = 0; d < longitud; d++) {
      escenario[x + d][y] = simbolo;
    }
    return true;
  }

  //Hacia abajo
  puedeDibujar = true;
  for (int d = 0; d < longitud; d++) {
    puedeDibujar = puedeDibujar && estaVacioEn(x, y + d);
  }
  if (puedeDibujar) {
    for (int d = 0; d < longitud; d++) {
      escenario[x][y + d] = simbolo;
    }
    return true;
  }

  //Hacia la izquierda
  puedeDibujar = true;
  for (int d = 0; d < longitud; d++) {
    puedeDibujar = puedeDibujar && estaVacioEn(x - d, y);
  }
  if (puedeDibujar) {
    for (int d = 0; d < longitud; d++) {
      escenario[x - d][y] = simbolo;
    }
    return true;
  }

  //Hacia arriba
  puedeDibujar = true;
  for (int d = 0; d < longitud; d++) {
    puedeDibujar = puedeDibujar && estaVacioEn(x, y - d);
  }
  if (puedeDibujar) {
    for (int d = 0; d < longitud; d++) {
      escenario[x][y - d] = simbolo;
    }
    return true;
  }
  return false;
}
boolean isNumeric(String str) {
  /*
    Gracias a http://tripsintech.com/arduino-isnumeric-function/
  */
  unsigned int stringLength = str.length();
  if (stringLength == 0) {
    return false;
  }

  boolean seenDecimal = false;

  for (unsigned int i = 0; i < stringLength; ++i) {
    if (isDigit(str.charAt(i))) {
      continue;
    }

    if (str.charAt(i) == '.') {
      if (seenDecimal) {
        return false;
      }
      seenDecimal = true;
      continue;
    }
    return false;
  }
  return true;
}
boolean esCoordenadaValida(String coordenadas, int &x, int &y) {
  int indice = coordenadas.indexOf(",");
  if (indice != -1) {
    String supuestoX = coordenadas.substring(0, indice),
           supuestoY = coordenadas.substring(indice + 1, coordenadas.length() - 2);
    if (isNumeric(supuestoX) && isNumeric(supuestoY)) {
      x = supuestoX.toInt(), y = supuestoY.toInt();
      return x >= 0 && y >= 0 && x < ANCHURA_LCD && y < ALTURA_LCD;
    }
  }
  return false;
}

Paso 7: Circuito en fritzing

Hice el circuito para un Arduino UNO, pero se puede modificar fácilmente para cualquier otro modelo. El círculo negro a la derecha es el buzzer.

Circuito de batalla naval en Arduino, diseñado con Fritzing
Circuito de batalla naval en Arduino, diseñado con Fritzing

Conclusión

Hemos terminado. Si tuve errores en el código, o tienes algunas dudas, no dudes en comentar. Cabe mencionar que podemos utilizar una LCD de 16 x 2, o de 20 x 4, o como sea mientras se pueda controlar con el módulo I2C.

En caso de que cambiemos la medida, también tenemos que cambiar las constantes en el código fuente.

Estoy aquí para ayudarte 🤝💻


Estoy aquí para ayudarte en todo lo que necesites. Si requieres alguna modificación en lo presentado en este post, deseas asistencia con tu tarea, proyecto o precisas desarrollar un software a medida, no dudes en contactarme. Estoy comprometido a brindarte el apoyo necesario para que logres tus objetivos. Mi correo es parzibyte(arroba)gmail.com, estoy como@parzibyte en Telegram o en mi página de contacto

No te pierdas ninguno de mis posts 🚀🔔

Suscríbete a mi canal de Telegram para recibir una notificación cuando escriba un nuevo tutorial de programación.

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *