Máquina Arcade

Por:

  • Diego Gil Luengo
  • Nerea Gonzalez Mogeda
  • Daniel Briones Bruna

Introducción

El proyecto consiste en la creación de una máquina arcade sencilla como imitación de una real.
El software consiste en un menú de selección de juegos. Estos son: 2048, Minesweeper (Buscaminsas) y Match 3 (3 en raya).

Componentes

NombreDetallesUnidadesPrecio IndividualPrecio Total
Arduino UNO1
Resistencias17
Placa Protoboard170 ptos25 euros5 euros
Pantalla HiLetgo 2.8″ ILI9341 TFT LCDILI9341 240*320 píxeles116 euros16 euros
Botones RUNCCI-YUN Botones más personalizados para más comodidadd60.10 euros0.60 euros
Cables M/M M/H260.262.6 euros

Desarollo y obstáculos de la práctica

Para poder realizar este proyecto el obstáculo principal fue la pantalla y sus limitaciones. Esta actualiza los píxeles desde la esquina superior izquierda a la inferior derecha. Los juegos disponibles estaban limitados a juegos por turnos u otros más complejos de la generación de las consolas de Atari.

Sobre el control del dispositivo, sigue los patrones evolutivos de las máquinas arcade. Las arcade tienen un contról único por máquina ya que solo deben reproducir un único juego. Las consolas comerciales solventan este problema consolidando un control genérico que abarque la mayor cantidad de juegos. Este patrón se aplica en el mando del proyecto con limitaciones en la disposición de los botones por la protoboard.

La memoria total de Arduino Uno es de 32KBytes. Se usa un 97% de la memoria total. Gracias a ciertos sacrificios de fidelidad y código de menor eficiente en cuanto a su tamaño, se ha evitado incrementar el precio del prototipo con expansiones de memoria. Una de las propuestas para el proyecto una vez encontrado el problema de la memoria fue la posibilidad de cargar juegos al conectarlo a una memoria externa pero se descarto por temas de coste y complejidad.

En el 2048 al principio fue dificil definir cómo íbamos a implementar el programa ya que hubo varias ideas diferentes mucho más complejas de lo que este debía de ser. Poco a poco, función por función fuimos definiendo qué funcionalidades serían más importantes para el programa. Cuanto más desarrollamos estas, fuimos encontrando maneras más simplificadas hasta que desaparecieron los errores.

Al realizar el 3 en raya tuvimos que decidir como se iba a llevara cabo el cambio de turnos. En un principio pensamos hacerlo como un juego de un único jugador de forma que deberíamos implementar una inteligencia artificial. Al final decidimos hacerlo de dos jugadores ya que, por falta de espacio, la inteligencia artificial resultaba muy costosa.

Vídeo de demostración

Diagrama del circuito

Adafruit

La pantalla usada es modelo ILI9341. Exsiste una libreria por Adafruit para este modelo de pantalla que se ha usado para comunicarse con la pantalla.

Se utiliza un sistema de coordenadas que inicia en la esquina superior derecha. Esta puede cambiar con la función de orientación de adafruit.

El sistema de color es RGB565 de 16 bits con preferencia al color verde.

Funciones de distintas formas:

Creación de maqueta

Para este proyecto necesitábamos crear la propia máquina arcade dónde se encuentra la pantalla y el mando que sale de la máquina con los seis botones correspondientes.

Lo primero que se hizo fue hacer un boceto de la idea que teníamos para la máquina.

Una vez establecida la idea general se crearon unos planos para ambas cajas. Estos planos no tenían los agujeros para los botones, la pantalla ni la salida de los cables. Estos huecos se recortaría directamente sobre el prototipo.

Finalmente se utilizó un cartón más fino para el mando y uno más resistente y grueso para la caja. Ambas piezas se pintaron de negro y se le añadieron el logo de la máquina y etiquetas a los botones con el fin de que quedase más estético.

Explicación de código

Para agilizar el desarrollo del software se dividió la carga de trabajo por segmentos. Estos son los juegos y los menus que los conectan entre si.

La función setup() inicializa la pantalla y loop() reproduce el selector de juegos de forma indefinida.

Para ahorrar memoria se generaron una serie de funciones comunes para todos los juegos relacionados con la generación de texto y menús.

El programa se comunica entre sus distintos segmentos de la siguiente forma:

Interfaz y menús – Diego

Para solventar el acceso entre los distintos juegos se preparó una interfaz con las funciones necesarias para navegar entre ellos. Al iniciar el programa, se muestra una pantalla inicial con el logotipo de la arcade para dar a entender que toda la circuitería relacionada con la pantalla funciona correctamente.

Después, se muestra una pantalla de selección dinámica (ajusta su tamaño según el número de juegos) de los juegos en memoria. Permite seleccionar entre ellos gracias a una flecha que indica donde permanece el enfoque.

Dentro de los juegos, el contenido de los mismos ocupa un espacio cuadrado en el centro de la pantalla. El margen superio se usa para titular el juego actual y mostrar los comandos de confirmación de escape y terminación de cada juego y el margen inferior se usa para mostrar información relacionada a cada juego.

La memoria ocupada supone un 95% de la memoria total. Para ahorrar al máximo los bytes disponibles, se crearon distintas funciones comunes genéricas a las que recurrir como text() o endGamePromt(). Como se trabajó de forma independiente en cada programa, si no se hubisen consolidad estas funciones acabarían triplicadas a lo largo del todo el programa.

Las funciones setup y loop son las siguientes

void setup() {
  Serial.begin(9600);
  pinMode(TFT_LED, OUTPUT);
  digitalWrite(TFT_LED, HIGH);
  tft.begin();
  tft.setRotation(0);  //Vertical con los pines abajo
  tft.fillScreen(ILI9341_BLACK);

  pinMode(BUTTON_A, INPUT);
  pinMode(BUTTON_B, INPUT);
  pinMode(BUTTON_RIGHT, INPUT);
  pinMode(BUTTON_LEFT, INPUT);

  width = tft.width();
  height = tft.height();

  randomSeed(analogRead(A0));
  startupScreen();
}

void loop() {

  newScreen();
  int chosenGame = 0;
  String gameNames[] = { "ms", "2048", "match3" };

  chosenGame = startScreen(gameNames, 3);
  Serial.println(chosenGame);
  if(chosenGame == 1) {
    newScreen();
    headerText("Minesweeper");
    while(minesweeper()){

    }

  } else if(chosenGame == 2) {
    newScreen();
    headerText("2048");
    ini2048();
  } else if(chosenGame == 3) {
    newScreen();
    headerText("Match 3");
    playGame3in3();
  }
}
Las funciones comunes que usan todos los juegos son las siguientes:
void text(String text, int16_t cursorX, int16_t cursorY, uint16_t color, uint8_t size, boolean textWrap) {
  tft.setCursor(cursorX, cursorY);
  tft.setTextColor(color);
  tft.setTextSize(size);
  tft.setTextWrap(textWrap);
  tft.print(text);
}

void headerText(String text1) {
  tft.fillRect(0, 0, width, YDISTANCE, ILI9341_BLACK);
  text(text1, 10, 5, ILI9341_WHITE, 2, true);
}

void bottomText(String text1) {
  tft.fillRect(0, YDISTANCE + width, width, YDISTANCE, ILI9341_BLACK);
  text(text1, 10, 5 + YDISTANCE + width, ILI9341_WHITE, 2, true);
}

boolean endGamePrompt(String gameName) {
  headerText("Finished!");
  delay(1000);
  headerText("A-Retry//B-Exit");
  return checkRetry(gameName);
}

boolean endGamePromptBoolean(bool win, String gameName) {
  if(win){
    headerText("You Won!");
  } else {
    headerText("Game Over");
  }
  delay(1000);
  headerText("A-Retry//B-Exit");
  return checkRetry(gameName);
}

boolean endGamePrompt2P(bool winP1, String gameName) {
  if(winP1){
    headerText("Player 1 wins!");
  } else {
    headerText("Player 2 wins!");
  }
  delay(1000);
  headerText("A-Retry//B-Exit");
  return checkRetry(gameName);
}

boolean exitGamePrompt(String gameName) {
  headerText("A-Exit//B-Back");
  boolean selected = false;
  while(!selected) {
    if (digitalRead(BUTTON_A) == HIGH) {
      selected = true;
      return true;
    } else if (digitalRead(BUTTON_B) == HIGH) {
      selected = true;
      headerText(gameName);
      return false;
    }
  }
}

boolean checkRetry(String gameName) {
  boolean selected = false;
  while(!selected) {
    if (digitalRead(BUTTON_A) == HIGH) {
      selected = true;
      tft.fillRect(0, YDISTANCE, width, width + YDISTANCE, ILI9341_BLACK);
      headerText(gameName);
      return true;
    } else if (digitalRead(BUTTON_B) == HIGH) {
      selected = true;
      return false;
    }
  }
}
Tres en raya – Nerea

La función encargada de contener este juego es playGame3in3. Tiene como variables la matriz con las X o los O, el jugador que está jugando (1 o 2), la posición del cursor tanto en X como en Y y un boolean para controlar el cambio de turno entre los dos jugadores. Lo primero que hará será inicializar la matriz y pintar el tablero.

Los turnos del juego se encierran en un bucle que se ejecutará hasta alcanzar la condición de gameOver. Para desplazarnos por el tablero se suman o restan a los contadores de la X y la Y dependiendo del botón pulsado y una vez seleccionamos la casilla con el botón A se comprueba si la osición es válida y en ese caso se añadirá a la matriz y se pintará la figura correspondiente al jugador.

La función encargada de contener este juego es playGame3in3. Tiene como variables la matriz con las X o los O, el jugador que está jugando (1 o 2), la posición del cursor tanto en X como en Y y un boolean para controlar el cambio de turno entre los dos jugadores. Lo primero que hará será inicializar la matriz y pintar el tablero.

Los turnos del juego se encierran en un bucle que se ejecutará hasta alcanzar la condición de gameOver. Para desplazarnos por el tablero se suman o restan a los contadores de la X y la Y dependiendo del botón pulsado y una vez seleccionamos la casilla con el botón A se comprueba si la osición es válida y en ese caso se añadirá a la matriz y se pintará la figura correspondiente al jugador.

La matriz se inicializa a valores vacíos en la función iniciarGRID.

El tablero se pinta con drawGrid. Esta función trabaja con los márgenes preestablecidos como variables constantes y dibuja líneas con la función propia de la librería Adafruit_ILI9341 drawFast(H/V)Line.

Para indicarle al jugador la casilla sobre la que se encuentra se llama a la función drawCursor que pinta un recuadro interior en la celda correspondiente.

Cuando se selecciona una casilla se comprueba que la casilla está vacía y está dentro de los límites de la cuadrícula con isValidMove.

Cada vez que se añade a la matriz una ficha se llama a la función updateSquare que es la encargada de pintar las figuras centradas en la casilla en la que se encuentra el cursor.

Constantemente se comprueba si el juego ha llegado a su fin mediante la función gameOver que a su vez llama a checkWinner y boardFull.

checkWinner comprueba si alguno de los dos jugadores ha ganado. Esto ocurrirá si hay una fila o una columna o alguna de las dos diagonales con todo X o O. Devuelve 1 si gana el jugador 1 y 2 si gana el jugador dos. Si ninguno de los dos gana devuelve un 0.

boardFull comprueba si el tablero está lleno y para ello recorre toda la matriz comprobando los vacíos.

2048 – Daniel

La función ini2048 es el setup de el 2048. En este se inicializa la pantalla con sus colores, se crea la matriz y se añaden dos nuevos cuadrados iniciales en posiciones aleatorias con la función random (la semilla ya fue inicializada en el setup del menu). Además, usaremos las funciones dibujarCuadrado e imprimirMatriz (que explicamos a continuación) para mostrar la matriz inicial por pantalla.

La función imprimirMatriz simplemente se dedicará a hacer uso de la función dibujarCuadrado para imprimir cada uno de los cuadrados de la matriz diferentes de 0.

La función dibujarCuadrado anteriormente estaba implementada con if else pero supusimos que usar un switch haría la comprobación más rápida aunque supusiera hacer un código más extenso. Como se puede ver, la función usa tft.fillRect (una función perteneciente a este tipo de pantallas) para dibujar el cuadrado con el color y tamaño correspondiente. Además, se hace uso de la función text explicada más arriba para escribir el número de cada cuadrado. TAMANOCUAD es la variable global que utiliza el ancho y largo de la pantalla para calcular el tamaño de los cuadrados.

nuevoCuadAleatorio: la función crea un nuevo cuadrado en uno de los huecos libres. Cada vez que se realiza un movimiento se llama a esta función y crea un nuevo cuadrado si es posible. Este hace uso de la función seleccionarHuecoLibre para encontrar donde puede crear uno nuevo. Si esta devuelve -1, significa que la matriz está llena y no se puede crear uno nuevo. En caso contrario rellena el hueco obtenido en la función. Se ha hecho un random del 1 al 5 para que haya una pequeña probabilidad de que al crearlo sea un 4 en vez de un 2. La fila y columna es solo la traducción del valor obtenido en la función que explicamos a continuación.

seleccionarHuecoLibre: recibe la matriz con los valores de los diferentes cuadrados. Mediante el bucle presentado se va comprobando cada posición de la matriz para descubrir si esta vacía, o de otra manera escrito, su valor es 0. Cada posición que guardemos lo haremos de la siguiente manera: multiplicaremos el valor de la fila por el numero de filas total y le sumaremos la colmuna para luego más tarde poder «desmenuzarlo» en la función nuevoCuadAleatorio. Además aumentaremos el valor de cantidadHuecosLibres para saber cuantos huecos hemos encontrado en total. Finalmente, el último if, sirve para elegir una posición aleatoria entre todas las que hemos encontrado (si hemos encontrado alguna).

A partir de aquí tenemos el loop que hemos implementado con un while y una variable endGame para terminar el bucle cuando se llegue a 2048 o se quede el jugador sin movimientos. Por cada iteración del bucle se hará lo siguiente: Primero, comprobar si se puede realizar algún movimiento hacia algún lado usando la función checkColsFils. Si no es posible ningún movimiento, el juego termina y volvemos al menú. En caso contrario, comprobamos si hay algún botón siendo presionado (en este caso vamos a explicar si el botón de arriba es el presionado). Si es así, comprobamos si era posible moverse en esa direccióny en caso afirmativo entramos en la condición. A partir de aquí, con el bucle iremos columna por columna por cada fila comprobando primero si se pueden sumar dos cuadrados o si se puede mover un cuadrado porque hay un hueco. En caso de que haya un hueco y movamos los cuadrados, deberemos volver una iteración atrás para comprobar si podemos hacer algo con ese cuadrado. Finalmente después de comprobar todo, añadiremos un nuevo cuadrado e imprimiremos de nuevo la matriz. Las siguientes imagenes es para los demás botones que funcionan de la misma manera pero en diferentes direcciones:

checkColsFils: sirve para comprobar en cada turno si es posible hacer un movimiento cuando se pulsa un boton. Según el botón que se pulse se comprueba en una dirección u otra según los parámetros. Además hay una funcionalidad añadida para cuando los parámetros son 0, 0 que comprueba en la matriz si hemos conseguido llegar a 2048.

Minesweeper – Diego

Minesweeper o buscaminas perminte seleccionar el número de minas (1-9) en el tablero. Los visuales tienen el objetivo de replicar el diseño del original y su comportamiento es casi igual.
La diferencia principal al original es el comportamiento al seleccionar una casilla vacía, que revela las adyacentes en vez de todas las vacías de su grupo por limitaciones de memoria.

El juego inicializa el tablero y lo imprime por pantalla. Continua con el bucle del juego esperando los comandos hasta llegar a un estado de finalización.
El código relacionado con la impresión es dinámico. Puede aplicarse a otras pantallas con otra resolución y se imprimirá de forma esperada. La actualización de la pantalla es rápida al actualizar únicamente un pequeño cuadrado de píxeles cada vez.

También te podría gustar...

Deja una respuesta

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