Cómo crear el juego de Snake para Angular.

Bienvenidos otra vez a la web 1938.com.es. En el artículo de hoy os explicaremos cómo crear un juego con Angular, más exactamente el juego SNAKE que conocemos muy bien y seguramente much@s de nosotr@s hemos jugado con nuestros Nokia 3310. El resultado de este tutorial lo encontraréis en la siguiente url https://1938.com.es/snake-angular y el repositorio lo podéis encontrar en https://github.com/al118345/snakeGame1938Web. Además, os dejo el siguiente video explicativo:

Si no recordáis cómo se jugaba, es muy sencillo. En este juego debemos guiar a una serpiente a lo largo de un recuadro en el que debemos girar a izquierda, derecha, arriba y abajo con el objetivo de comernos diferentes alimentos. Cada vez que la serpiente recolectaba una de las pequeñas piezas cuadriculadas se obtenía una mayor puntación pero , al mismo tiempo, la serpiente se volvía cada vez más larga, ocupando más pantalla y, por lo tanto, siendo más difícil evitar mordernos a nosotros mismo o evitar los bordes.

Ejemplo del juego SNAKE en el Nokia 3310
3. Ejemplo del juego SNAKE en el Nokia 3310
Si queréis más información del juego y otros temas relacionados, os invito a que os animéis a acceder a la información de Wikipedia del juego: https://es.wikipedia.org/wiki/La_serpiente_ (videojuego)

Angular con CSS

Para este proyecto he escogido utilizar un proyecto nuevo en Angular con CSS para definir los diferentes estilos. Por supuesto,

ng new snakeGame1938web --routing=true --style=css

Base del proyecto

Una vez creado, vamos a definir la estructura básica del juego. Consistirá en crear un simple tablero y además mostrar un marcador. Con este objetivo borraremos el contenido del fichero app.component.html y lo vamos a sustituir por:

<div class="container">
  <p></p>
  <p></p>
  <h1>Juego SNAKE</h1>
  <p></p>
  <p></p>
  <div  class="container2"  style="background-color: #282846" >
    <div class="container2" #fullScreen>
      <div class="score-card">
        <div class="score-board">
          <h1>Score</h1>
        </div>
        <div class="restart-button" >
          RESTART
        </div>
      </div>
      <div class="game-board">
      </div>
    </div>
  </div>
</div>

Además, añadimos los correspondientes estilos necesarios para que la estructura tome la forma correcta.

.container2 {
  margin: 0  ;
  height: 100%  ;
  width: 100%  ;
  display: flex  ;
  justify-content: space-evenly  ;
}
.container2 {
  padding: 20px  ;
  color: #d8ebe4  ;
  display: flex  ;
  justify-content: space-between  ;
  flex-direction: column  ;
  align-items: center  ;
}
.score-card {
  padding: 20px  ;
  color: #d8ebe4  ;
  display: flex  ;
  justify-content: space-between  ;
  flex-direction: column  ;
  align-items: center  ;
}
.container2{
  display: flex  ;
  justify-content: center  ;
  align-items: center  ;
  flex-direction: column ;
}
.score-card .score-board {
  display: flex  ;
  justify-content: center  ;
  align-items: center  ;
  flex-direction: column  ;
}
.container2 .score-card .restart-button {
  padding: 10px 20px  ;
  color: #282846 !important ;
  border-radius: 20px  ;
  height: 40px  ;
  font-weight: bold  ;
  display: flex  ;
  justify-content: center  ;
  align-items: center  ;
  background-color: #d8ebe4  ;
  cursor: pointer  ;
  z-index: 99  ;
}
.container2 .game-board {
  background-color: #d8ebe4  ;
  /* width: 100vmin  ;  */
  width: 100vmin  ;
  height: 100vmin  ;
  display: grid  ;
  align-self: center  ;
  grid-template-rows: repeat(21, 1fr)  ;
  grid-template-columns: repeat(21, 1fr)  ;
}
.container2 .game-board.blur {
  filter: blur(4px)  ;
}

Tablero Snake
1 Ejemplo tablero Snake

Ahora nos tocará posicionar a la serpiente y su comida. Para ello vamos a necesitar definir la lógica del juego.

import {AfterViewInit, Component, OnInit} from '@angular/core';
import {Food} from "./game-engine/food";
import {Snake} from "./game-engine/snake";

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent  implements OnInit, AfterViewInit {
  title = 'snakeGame1938web';
  gameBoard: any;
  snake = new Snake();
  food = new Food(this.snake);

  ngAfterViewInit() {
    this.gameBoard = document.querySelector('.game-board');
    window.requestAnimationFrame(this.start.bind(this));
  }

  ngOnInit(): void {
  }

  start(currentTime: any) {

    window.requestAnimationFrame(this.start.bind(this));

    this.update();
    this.draw();
  }

  update() {
    this.snake.update();
    this.food.update();
  }

  draw() {
    this.gameBoard.innerHTML = '';
    this.snake.draw(this.gameBoard);
    this.food.draw(this.gameBoard);
  }
}

Además, hemos añadido lo siguientes ficheros:

  • game-engine/food.ts: Este fichero contiene toda la lógica relacionada con el dibujo de la comida, su valor y su localización aleatoria.
  • game-engine/snake.ts: Documento encargado de representar en el tablero la serpiente y aumentar su tamaño cada vez que coma una nueva pieza de comida.
  • game-engine/gameboard-grid.util.ts: Parte del código encargado de obtener coordenadas aleatorias del tablero y de validar si está o no está dentro del mismo unas coordenadas.
  • game-engine/input.ts: Función encargada de gestionar la interacción con el tablero.
Tablero Snake con la serpiente dibujada
1 Tablero Snake con la serpiente dibujada.

Con todo ello ya hemos conseguido dibujar la serpiente y la recompensa.

Lógica del juego.

Ahora toca implementar el código para la gestión del teclado, el movimiento de la serpiente y la gestión de las recompensas. Empezaremos por la gestión del teclado.

Existen dos mecanismos, por una parte, cuando estemos en un ordenador, utilizaremos la gestión del teclado con las funciones del fichero game-engine/input.ts: que son:

 inputDirection = { x: 0, y: 0 };
  lastInputDirection = { x: 0, y: 0 };


  getInputs() {
    window.addEventListener('keydown', e => {
      this.setDirection(e.key);
    })
  }

  setDirection(direction: String) {
    switch (direction) {
      case 'ArrowUp':
        if (this.lastInputDirection.y !== 0) break;
        this.inputDirection = { x: 0, y: -1 };
        break;
      case 'ArrowDown':
        if (this.lastInputDirection.y !== 0) break;
        this.inputDirection = { x: 0, y: 1 };
        break;
      case 'ArrowLeft':
        if (this.lastInputDirection.x !== 0) break;
        this.inputDirection = { x: -1, y: 0 };
        break;
      case 'ArrowRight':
        if (this.lastInputDirection.x !== 0) break;
        this.inputDirection = { x: 1, y: 0 };
        break;
    }
  }

  getInputDirection() {
    this.lastInputDirection = this.inputDirection;
    return this.inputDirection;
  }

Para los teléfonos móviles, lo que hacemos es añadir una parte del código que corresponden a unos botones que simulan un pad. Para ello, añadiremos el siguiente código al app.component.html:

  <div class="mobile-controls">
    <h2>Pad para el teléfono</h2>
    <nav class="o-pad">
      <a class="up" (click)="dpadMovement('ArrowUp')"></a>
      <a class="right" (click)="dpadMovement('ArrowRight')"></a>
      <a class="down" (click)="dpadMovement('ArrowDown')"></a>
      <a class="left" (click)="dpadMovement('ArrowLeft')"></a>
    </nav>
  </div>

Ahora será necesario añadir la lógica de los botones para los teléfonos, es decir, una acciones que simule el movimiento de presionar hacia arriba, abajo, derecha e izquierda. Además, aprovecharemos para añadir todas las funciones que se encargan de actualizar el tablero y reposicionar la serpiente.

Añadimos el siguiente código al app.component.ts:

  ngOnInit(): void {
    this.snake.listenToInputs();
  }
  dpadMovement(direction: string) {
    this.snake.input.setDirection(direction);
  }

Añadimos el siguiente código css para obtener la vista de un teléfono móvil:

.container2 .mobile-controls {
  display: none  ;
  flex: 1  ;
}
.container2 .mobile-controls .o-pad {
  position: relative  ;
  width: 200px  ;
  height: 200px  ;
  border-radius: 50%  ;
  overflow: hidden  ;
}
.container2 .mobile-controls .o-pad:after {
  content: ""  ;
  position: absolute  ;
  z-index: 2  ;
  width: 20%  ;
  height: 20%  ;
  top: 50%  ;
  left: 50%  ;
  background: #ddd  ;
  border-radius: 50%  ;
  transform: translate(-50%, -50%)  ;
  display: none  ;
  transition: all 0.25s   ;
  cursor: pointer  ;
}
.container2 .mobile-controls .o-pad:hover:after {
  width: 30%  ;
  height: 30%  ;
}
.container2 .mobile-controls .o-pad a {
  display: block  ;
  position: absolute  ;
  -webkit-tap-highlight-color: rgba(255, 255, 255, 0)  ;
  width: 50%  ;
  height: 50%  ;
  text-align: center  ;
  transform: rotate(45deg)  ;
  border: 1px solid rgba(0, 0, 0, 0.2)  ;
}
.container2 .mobile-controls .o-pad a:before {
  content: ""  ;
  position: absolute  ;
  width: 60%  ;
  height: 60%  ;
  top: 50%  ;
  left: 50%  ;
  background: rgba(255, 255, 255, 0.1)  ;
  border-radius: 50%  ;
  transform: translate(-50%, -50%)  ;
  transition: all 0.25s  ;
  cursor: pointer  ;
  display: none  ;
}
.container2 .mobile-controls .o-pad a:after {
  content: ""  ;
  position: absolute  ;
  width: 0  ;
  height: 0  ;
  border-radius: 5px  ;
  border-style: solid  ;
  transform: translate(-50%, -50%) rotate(-45deg)  ;
  transition: all 0.25s  ;
}
.container2 .mobile-controls .o-pad a.up {
  bottom: 50%  ;
  left: 50%  ;
  transform: translate(-50%, -20%) rotate(45deg)  ;
  border-top-left-radius: 50%  ;
  z-index: 1  ;
}
.container2 .mobile-controls .o-pad a.up:hover {
  background: linear-gradient(315deg, rgba(255, 255, 255, 0) 15%, rgba(255, 255, 255, 0.4) 100%)  ;
}
.container2 .mobile-controls .o-pad a.up:before {
  left: 57%  ;
  top: 57%  ;
}
.container2 .mobile-controls .o-pad a.up:after {
  left: 53%  ;
  top: 53%  ;
  border-width: 0 var(--tri-lrg-a) var(--tri-lrg-b) var(--tri-lrg-a)  ;
  border-color: transparent transparent var(--arrowcolor) transparent  ;
}
.container2 .mobile-controls .o-pad a.up:active:after {
  border-bottom-color: #333  ;
}
.container2 .mobile-controls .o-pad a.down {
  top: 50%  ;
  left: 50%  ;
  transform: translate(-50%, 20%) rotate(45deg)  ;
  border-bottom-right-radius: 50%  ;
  z-index: 1  ;
}
.container2 .mobile-controls .o-pad a.down:hover {
  background: linear-gradient(135deg, rgba(255, 255, 255, 0) 15%, rgba(255, 255, 255, 0.4) 100%)  ;
}
.container2 .mobile-controls .o-pad a.down:before {
  left: 43%  ;
  top: 43%  ;
}
.container2 .mobile-controls .o-pad a.down:after {
  left: 47%  ;
  top: 47%  ;
  border-width: var(--tri-lrg-b) var(--tri-lrg-a) 0px var(--tri-lrg-a)  ;
  border-color: var(--arrowcolor) transparent transparent transparent  ;
}
.container2 .mobile-controls .o-pad a.down:active:after {
  border-top-color: #333  ;
}
.container2 .mobile-controls .o-pad a.left {
  top: 50%  ;
  right: 50%  ;
  transform: translate(-20%, -50%) rotate(45deg)  ;
  border-bottom-left-radius: 50%  ;
  border: none  ;
}
.container2 .mobile-controls .o-pad a.left:hover {
  background: linear-gradient(225deg, rgba(255, 255, 255, 0) 15%, rgba(255, 255, 255, 0.4) 100%)  ;
}
.container2 .mobile-controls .o-pad a.left:before {
  left: 57%  ;
  top: 43%  ;
}
.container2 .mobile-controls .o-pad a.left:after {
  left: 53%  ;
  top: 47%  ;
  border-width: var(--tri-lrg-a) var(--tri-lrg-b) var(--tri-lrg-a) 0  ;
  border-color: transparent var(--arrowcolor) transparent transparent  ;
}
.container2 .mobile-controls .o-pad a.left:active:after {
  border-right-color: #333  ;
}
.container2 .mobile-controls .o-pad a.right {
  top: 50%  ;
  left: 50%  ;
  transform: translate(20%, -50%) rotate(45deg)  ;
  border-top-right-radius: 50%  ;
  border: none  ;
}
.container2 .mobile-controls .o-pad a.right:hover {
  background: linear-gradient(45deg, rgba(255, 255, 255, 0) 15%, rgba(255, 255, 255, 0.4) 100%)  ;
}
.container2 .mobile-controls .o-pad a.right:before {
  left: 43%  ;
  top: 57%  ;
}
.container2 .mobile-controls .o-pad a.right:after {
  left: 47%  ;
  top: 53%  ;
  border-width: var(--tri-lrg-a) 0 var(--tri-lrg-a) var(--tri-lrg-b)  ;
  border-color: transparent transparent transparent var(--arrowcolor)  ;
}
.container2 .mobile-controls .o-pad a.right:active:after {
  border-left-color: #333  ;
}
.container2 .mobile-controls .o-pad a:hover:after {
  left: 50%  ;
  top: 50%  ;
}
.container2 {
  padding: 20px  ;
  color: #d8ebe4  ;
  display: flex  ;
  justify-content: space-between  ;
  /* flex-direction: column  */
  flex-direction: column  ;
  align-items: center  ;
}
.score-card {
  padding: 20px  ;
  color: #d8ebe4  ;
  display: flex  ;
  justify-content: space-between  ;
  /* flex-direction: column  */
  flex-direction: column  ;
  align-items: center  ;
}

.container2{
  display: flex  ;
  justify-content: center  ;
  align-items: center  ;
  flex-direction: column ;
}

.score-card .score-board {
  display: flex  ;
  justify-content: center  ;
  align-items: center  ;
  flex-direction: column  ;
}
.container2 .score-card .restart-button {
  padding: 10px 20px  ;
  color: #282846 !important ;
  border-radius: 20px  ;
  height: 40px  ;
  font-weight: bold  ;
  display: flex  ;
  justify-content: center  ;
  align-items: center  ;
  background-color: #d8ebe4  ;
  cursor: pointer  ;
  z-index: 99  ;
}
.container2 .game-board {
  background-color: #d8ebe4  ;
  /* width: 100vmin  ;  */
  width: 100vmin  ;
  height: 100vmin  ;
  display: grid  ;
  align-self: center  ;
  grid-template-rows: repeat(21, 1fr)  ;
  grid-template-columns: repeat(21, 1fr)  ;
}
.container2 .game-board.blur {
  filter: blur(4px)  ;
}
.game-over {
  position: fixed  ;
  display: flex  ;
  justify-content: center  ;
  align-items: center  ;
  height: 100%  ;
  width: 100%  ;
  color: #fed049  ;
  z-index: 1  ;
  -webkit-text-stroke-width: 1px  ;
  -webkit-text-stroke-color: black  ;
}
.game-over h1 {
  font-size: 3em  ;
}

@media only screen and (max-width: 1025px) {
  .container {
    flex-direction: column;
  }
  .container .score-card {
    flex-direction: column !important;
  }
  .mobile-controls {
    background-color: whitesmoke;
    margin-top: 10px;
    display: flex !important;
    justify-content: center;
    align-items: center;
    width: 100%;
  }
}
Tablero Snake para teléfono móvil.
Tablero Snake para teléfono móvil.

Funcionamiento del juego.

Para terminar, vamos a añadir el funcionamiento del juego, es decir, existirán unas normas que serán:
  • No puede comerse la serpiente a sí misma.
  • No puede salir del tablero.
  • Cada recompensa obtenida al comer un objeto, aumenta el tamaño y la velocidad de la serpiente.

Primero actualizamos el código de forma que vayamos actualizando la vista a la par que vamos avanzando con la serpiente por la pantalla. Entonces, cómo funciones principales que añadiremos al app.component.ts tenemos:

  • checkDeath: función para comprobar si la serpiente no ha chocado contra el final del tablero o se ha mordido a sí misma.
  • update: actualiza el dibujo.
update() {
    this.snake.update();
    this.food.update();
    this.checkDeath();
  }
start(currentTime: any) {
    if (this.gameOver) {
      return console.log('Game Over');
    }

    window.requestAnimationFrame(this.start.bind(this));
    const secondsSinceLastRender = (currentTime - this.lastRenderTime) / 1000;
    if (secondsSinceLastRender < 1 / this.snakeSpeed) {
      return;
    }
    this.lastRenderTime = currentTime;

    this.update();
    this.draw();
  }

checkDeath() {
    this.gameOver = outsideGrid(this.snake.getSnakeHead()) || this.snake.snakeIntersection();
    if (!this.gameOver) {
      return;
    }
    this.gameBoard.classList.add('blur');
  }


  get snakeSpeed() {
    const score = this.food.currentScore;
    if (score < 10) {
      return 4;
    }
    if (score > 10 && score < 15) {
      return 5;
    }
    if (score > 15 && score < 20) {
      return 6;
    }
    return 7;
  }
A continuación tenemos en la vista que añadir el score con la puntación que mostramos a continuación:
<div class="score-card">
        <div class="score-board">
          <h1>Score</h1>
          <h3>{{food?.currentScore}}</h3>
        </div>
        <div class="restart-button" >
          RESTART
        </div>
      </div>
      <div class="game-over" *ngIf="gameOver">
        <h1>Game Over</h1>
      </div>

El resultado lo podéis visitar en la web https://1938.com.es/snake-angular

Recordar que, el repositorio lo podéis descargar de la siguiente dirección: https://github.com/al118345/snakeGame1938Web además, el repositorio original lo podeis encontrar en la siguiente dirección https://github.com/al118345/Angular-Snake-Game