Tutorial de juegos con JavaScript – Construye un clon de Stick Hero con HTML Canvas + JavaScript

En este tutorial, aprenderás cómo crear un juego inspirado en Stick Hero mediante JavaScript puro y el lienzo de HTML. Vamos a recrear Stick Hero [https//apps.apple.com/us/app/stick-hero/id918338898], un juego móvil publicado por KetchApp. Veremos cómo funciona el juego en general y cómo utilizar JavaScript para...

En este tutorial, aprenderás cómo crear un juego inspirado en Stick Hero, utilizando JavaScript puro y el elemento canvas de HTML.

Vamos a recrear Stick Hero, un juego móvil publicado por KetchApp. Repasaremos cómo funciona el juego en general, cómo usar JavaScript para dibujar en un elemento <canvas>, cómo agregar lógica de juego y animar el juego, y cómo funciona el manejo de eventos.

Al final de esta guía, habrás construido el juego completo utilizando JavaScript puro.

A lo largo del tutorial, utilizaremos JavaScript para manipular el estado del juego y el elemento canvas de HTML para renderizar la escena del juego. Para aprovechar al máximo este tutorial, deberías tener una comprensión básica de JavaScript. Pero incluso si eres principiante, aún puedes seguir y aprender a medida que avanzamos.

¡Comencemos y construyamos nuestro propio juego Stick Hero utilizando JavaScript y HTML canvas!

Si prefieres formato de video, también puedes ver este tutorial en YouTube aquí.

Tabla de contenidos

  1. El juego Stick Hero
  2. Fases del juego
  3. Las partes principales del juego
  4. Cómo inicializar el juego
  5. La función de dibujo
  6. Manejo de eventos
  7. El bucle principal de animación
  8. Resumen

El juego Stick Hero

En este juego, controlas a un héroe que camina de plataforma en plataforma estirando un palo que sirve como puente. Si el palo tiene el tamaño correcto, el héroe puede cruzar con seguridad a la siguiente plataforma. Pero si el palo es demasiado corto o demasiado largo, el héroe caerá.

Puedes encontrar una versión jugable del juego que estamos a punto de crear en CodePen, donde también puedes ver el código fuente final. Pruébalo antes de adentrarnos en los detalles.

También puedes echar un vistazo al juego original tanto en iOS como en Android.

https://codepen.io/HunorMarton/embed/preview/xxOMQKg

Fases del juego

El juego tiene cinco fases diferentes que se repiten una y otra vez hasta que el héroe cae.

  1. Inicialmente, el juego está esperando la entrada del usuario y no está sucediendo nada.
  2. Luego, una vez que el jugador mantiene presionado el mouse, el juego está estirando un palo hacia arriba hasta que se suelta el mouse.
  3. Luego, una vez que se suelta el mouse, el palo comienza a girar y cae, con suerte, sobre la siguiente plataforma.
  4. Si es el caso, entonces el héroe camina a lo largo del palo hasta la siguiente plataforma.
  5. Finalmente, una vez que el héroe llega a la siguiente plataforma, toda la escena transiciona hacia la izquierda para centrar al héroe y la siguiente plataforma. Luego, todo el bucle se reinicia desde el principio. El juego espera la entrada del usuario y, una vez que el jugador mantiene presionado el mouse, se dibuja un nuevo palo.

En un escenario menos favorable, las mismas fases se suceden, pero en la fase de caminar, si el otro extremo del palo no cae en la siguiente plataforma, el héroe solo caminará hasta el borde del palo y luego caerá.

Stick-Hero
Las fases del juego

Las Partes Principales del Juego

¿Cómo lo realizamos en código? Este juego tiene básicamente tres partes. Estado del juego, la función draw y la función animate.

Tenemos un estado del juego que es una colección de variables que definen cada parte del juego. Incluye la fase actual, la posición del héroe, las coordenadas de las plataformas, el tamaño y la rotación de los palos, y así sucesivamente.

let phase = "esperando"; // esperando | estirando | girando | caminando | en transición | cayendolet lastTimestamp; // La marca de tiempo del ciclo de animación anteriorlet heroX; // Cambia al moverse hacia adelantelet heroY; // Solo cambia al caerlet sceneOffset; // Mueve todo el juegoleet platforms = [];let sticks = [];let score = 0;...

Luego, tendremos dos funciones principales: una que pinta la escena en la pantalla basada en este estado (esta será la función draw), y otra que cambiará gradualmente este estado para que parezca una animación (esta será la función animate). Finalmente, también tendremos manejo de eventos que hará que se inicie el ciclo de animación.

Cómo Inicializar el Juego

Para comenzar, inicialicemos el proyecto con un archivo HTML, CSS y JavaScript simples. Estableceremos el esqueleto del código y luego inicializaremos el estado del juego.

El HTML

La parte HTML de este juego es muy simple. La mayor parte del juego estará dentro del elemento <canvas>. Vamos a usar JavaScript para dibujar en este lienzo. También tenemos un elemento div que mostrará la puntuación y un botón de reinicio.

En el encabezado, también cargamos nuestros archivos CSS y JavaScript. Observa la etiqueta defer al cargar el script. Esto ejecutará el script solo después de que el resto del HTML se haya cargado, por lo que podemos acceder a partes del HTML (como el elemento canvas) de inmediato en nuestro script.

<!DOCTYPE html><html>  <head>    <title>Stick Hero</title>    <link rel="stylesheet" href="index.css" />    <script src="index.js" defer></script>  </head>  <body>    <div class="container">      <canvas id="game" width="375" height="375"></canvas>      <div id="score"></div>      <button id="restart">REINICIAR</button>    </div>  </body></html>

El CSS

El CSS tampoco contiene demasiadas cosas. Pintamos el juego en el elemento canvas y el contenido del elemento canvas no puede tener estilo con CSS. Aquí solo se aplica estilo a la posición de nuestro canvas, al elemento de puntuación y al botón de reinicio.

Observa que el botón de reinicio por defecto está invisible. Vamos a hacerlo visible usando JavaScript una vez que el juego termine.

html,body {  height: 100%;}body,.container {  display: flex;  justify-content: center;  align-items: center;}.container {  position: relative;  font-family: Helvetica;}canvas {  border: 1px solid;}#score {  position: absolute;  top: 30px;  right: 30px;  font-size: 2em;  font-weight: 900;}#restart {  position: absolute;  display: none;}

El Esquema de Nuestro Archivo JavaScript

Y finalmente, la parte JavaScript es donde reside toda la magia. Para simplificar, he colocado todo en un solo archivo, pero siéntete libre de dividirlo en varios archivos.

Vamos a introducir algunas variables más y algunas funciones más, pero este es el esquema del archivo. Se incluyen las siguientes cosas:

  • Definimos varias variables que juntas forman el estado del juego. Más sobre sus valores en la sección sobre cómo inicializar el estado.
  • Vamos a definir algunas variables como configuración, como el tamaño de las plataformas y qué tan rápido debería moverse el héroe. Las cubriremos en la sección de dibujo y en el bucle principal.
  • Una referencia al elemento <canvas> en HTML, y obtener el contexto de dibujo del mismo. Esto será utilizado por la función draw.
  • Una referencia al elemento de puntuación y al botón de reinicio en HTML. Actualizaremos la puntuación cada vez que el héroe atraviese una nueva plataforma. Y mostraremos el botón de reinicio una vez que el juego haya terminado.
  • Inicializamos el estado del juego y pintamos la escena llamando a la función resetGame. Esta es la única llamada de función de nivel superior.
  • Definimos la función draw que dibujará la escena en el elemento canvas basándose en el estado.
  • Configuramos los manejadores de eventos para los eventos de mousedown y mouseup.
  • Definimos la función animate que manipulará el estado.
  • Y tendremos algunas funciones de utilidad que discutiremos más adelante.
// Estado del juegolet phase = "esperando"; // esperando | estirando | girando | caminando | transicionando | cayendolet lastTimestamp; // La marca de tiempo del ciclo de animación anteriorlet heroX; // Cambia al moverse hacia adelanteheroY; // Solo cambia al caerlet sceneOffset; // Mueve todo el juegoletpPlatforms = [];let sticks = [];let score = 0;// Configuración...// Obtener el elemento del lienzoconst canvas = document.getElementById("game");// Obtener el contexto de dibujadoconst ctx = canvas.getContext("2d");// Otros elementos de la IUconst scoreElement = document.getElementById("score");const restartButton = document.getElementById("restart");// Comenzar el juegoreiniciarJuego();// Restablece el estado y diseño del juegofunction reiniciarJuego() {  ...  dibujar();}function dibujar() {  ...}window.addEventListener("mousedown", function (event) {  ...});window.addEventListener("mouseup", function (event) {  ...});function animar(marcaDeTiempo) {  ...}...

Cómo Inicializar el Estado

Para comenzar el juego, llamamos a la misma función que usamos para reiniciarlo: la función reiniciarJuego. Esta función inicializa/restablece el estado del juego y llama a la función dibujar para pintar la escena.

El estado del juego incluye las siguientes variables:

  • phase: La fase actual del juego. Su valor inicial es “esperando”.
  • lastTimestamp: Utilizado por la función animar para determinar cuánto tiempo ha transcurrido desde el último ciclo de animación. Lo cubriremos con más detalle más adelante.
  • platforms: Un arreglo que contiene los metadatos de cada plataforma. Cada plataforma está representada por un objeto con las propiedades x y w, que representan su posición en el eje X y su ancho. La primera plataforma siempre es la misma, como se define aquí, para asegurarse de que tenga un tamaño y posición razonables. Las siguientes plataformas se generan mediante una función auxiliar. A medida que avanza el juego, se generan más y más plataformas sobre la marcha.
  • heroX: La posición en el eje X del héroe. Por defecto, el héroe se coloca cerca del borde de la primera plataforma. Este valor cambiará durante la fase de caminata.
  • heroY: La posición en el eje Y del héroe. Por defecto, es cero. Solo cambia si el héroe está cayendo.
  • sceneOffset: A medida que el héroe avanza, necesitamos desplazar toda la pantalla hacia atrás para mantener al héroe centrado en pantalla. De lo contrario, el héroe caminará fuera de la pantalla. En esta variable, llevamos un registro de cuánto debemos desplazar hacia atrás toda la pantalla. Actualizaremos este valor durante la fase de transición. Por defecto, su valor es 0.
  • sticks: Metadatos de los palos. Mientras que el héroe solo puede estirar un palo a la vez, también necesitamos almacenar los palos anteriores para poder representarlos. Por lo tanto, la variable sticks también es un arreglo. Cada palo está representado por un objeto con las propiedades x, length y rotation. La propiedad x representa la posición inicial del palo que siempre coincide con la esquina superior derecha de la plataforma correspondiente. Su propiedad length crecerá en la fase de estiramiento, y su propiedad rotation irá de 0 a 90 grados en la fase de giro. O de 90 a 180 grados en la fase de caída. Inicialmente, el arreglo sticks tiene un palo ‘invisible’ con una longitud de 0. Cada vez que el héroe alcanza una nueva plataforma, se agrega un nuevo palo al arreglo.
  • score: La puntuación del juego. Muestra cuántas plataformas ha alcanzado el héroe. Por defecto, es 0.
function reiniciarJuego() {  // Restablecer estado del juego  phase = "esperando";  lastTimestamp = undefined;  // La primera plataforma siempre es la misma  platforms = [{ x: 50, w: 50 }];  generarPlataforma();  generarPlataforma();  generarPlataforma();  generarPlataforma();  // Inicializar posición del héroe  heroX = platforms[0].x + platforms[0].w - 30; // El héroe se coloca un poco antes del borde  heroY = 0;  // Cuánto debemos desplazar la pantalla hacia atrás  sceneOffset = 0;  // Siempre hay un palo, incluso si parece invisible (longitud: 0)  sticks = [{ x: platforms[0].x + platforms[0].w, length: 0, rotation: 0 }];  //Puntuación  score = 0;  // Reiniciar IU  restartButton.style.display = "none"; // Ocultar botón de reinicio  scoreElement.innerText = score; // Reiniciar visualización de puntuación  dibujar();}

Al final de esta función, también restablecemos la interfaz de usuario asegurándonos de que el botón de reinicio esté oculto y la puntuación se muestre como 0.

Una vez que hemos inicializado el estado del juego y restablecido la interfaz de usuario, la función resetGame llama a la función draw para pintar la pantalla por primera vez.

La función resetGame llama a una función de utilidad que genera una plataforma aleatoria. En esta función, definimos cuál es la distancia mínima entre dos plataformas (minumumGap) y cuál es la distancia máxima (maximumGap). También definimos cuál es el ancho mínimo de una plataforma y cuál es el ancho máximo.

Basándonos en estos rangos y las plataformas existentes, generamos los metadatos de una nueva plataforma.

function generatePlatform() {  const minimumGap = 40;  const maximumGap = 200;  const minimumWidth = 20;  const maximumWidth = 100;  // Coordenada X del borde derecho de la plataforma más lejana  const lastPlatform = platforms[platforms.length - 1];  let furthestX = lastPlatform.x + lastPlatform.w;  const x =    furthestX +    minimumGap +    Math.floor(Math.random() * (maximumGap - minimumGap));  const w =    minimumWidth + Math.floor(Math.random() * (maximumWidth - minimumWidth));  platforms.push({ x, w });}

La Función de Dibujo

La función draw pinta todo el lienzo basándose en el estado. Desplaza toda la interfaz de usuario por el desplazamiento, coloca al héroe en la posición y pinta las plataformas y los palos.

En comparación con la demostración funcional vinculada al principio del artículo, aquí solo pasaremos por una versión simplificada de la función de dibujo. No cubriremos la pintura de un fondo y simplificaremos la apariencia del héroe.

Utilizaremos esta función tanto para pintar la escena inicial como para animarla a lo largo de nuestro bucle de animación principal.

En la pintura inicial, algunas de las características que cubrimos aquí no serán necesarias. Por ejemplo, todavía no tenemos palos en la escena. Aun así, los cubriremos para no tener que reescribir esta función una vez que comencemos a animar el estado.

Todo lo que dibujamos en esta función se basa en el estado y no importa si el estado está en estado inicial o si estamos más avanzados en el juego.

Hemos definido un elemento <canvas> en HTML. Pero, ¿cómo pintamos cosas en él? En JavaScript, primero obtenemos el elemento de lienzo y luego obtenemos su contexto en algún lugar al principio de nuestro archivo. Luego podemos usar este contexto para ejecutar comandos de dibujo.

También definimos algunas variables de configuración al principio. Hacemos esto porque necesitamos usar estos valores en diferentes partes de nuestro juego y queremos mantener la consistencia.

  • canvasWidth y canvasHeight representan el tamaño del elemento del lienzo en HTML. Deben coincidir con lo que hemos establecido en HTML. Usamos estos valores en varios lugares.
  • platformHeight representa la altura de las plataformas. Usamos estos valores al dibujar las propias plataformas, pero también al posicionar al héroe y los palos.

La función de dibujo repinta toda la pantalla desde cero cada vez. Primero, asegurémonos de que esté vacía. Llamar a la función clearRect en el contexto de dibujo con los argumentos correctos se asegura de que borremos todo de él.

...<div class="container">  <canvas id="game" width="375" height="375"></canvas>  <div id="score"></div>  <button id="restart">REINICIAR</button></div>...
...// Obteniendo el elemento del lienzoconst canvas = document.getElementById("game");// Obteniendo el contexto de dibujoconst ctx = canvas.getContext("2d");...// Configuraciónconst canvasWidth = 375;const canvasHeight = 375;const platformHeight = 100;...function draw() {  ctx.clearRect(0, 0, canvasWidth, canvasHeight);  ...}...

Cómo Enmarcar la Escena

También queremos asegurarnos de que la escena tenga el enmarcado correcto. cuando utilizamos un lienzo, tenemos un sistema de coordenadas con el centro en la esquina superior izquierda de la pantalla que crece hacia la derecha y hacia abajo. En HTML establecemos los atributos de ancho y altura a 375 píxeles.

Stick-Hero.001
De forma predeterminada, el centro del sistema de coordenadas se encuentra en la esquina superior izquierda

Inicialmente, la coordenada 0, 0 se encuentra en la esquina superior izquierda de la pantalla, pero a medida que el héroe avanza, toda la escena debería desplazarse hacia la izquierda. De lo contrario, nos quedaríamos sin pantalla.

A medida que avanza el juego, actualizamos el valor de sceneOffset para hacer un seguimiento de este desplazamiento en el bucle principal. Podemos usar esta variable para traducir todo el diseño. Llamamos al comando translate para desplazar la escena en el eje X.

<code function draw() { ctx.clearRect(0, 0, canvasWidth, canvasHeight); // Guardar la transformación actual ctx.save(); // Desplazar la vista ctx.translate(-sceneOffset, 0); // Dibujar la escena drawPlatforms(); drawHero(); drawSticks(); // Restaurar la transformación en el último guardado ctx.restore();}

Es importante hacer esto antes de pintar cualquier cosa en el lienzo, porque el comando translate no mueve realmente nada en el lienzo. Todo lo que hemos pintado anteriormente en el lienzo se mantendrá como estaba.

En cambio, el comando translate desplaza el sistema de coordenadas. La coordenada 0, 0 ya no estará en la esquina superior izquierda, sino fuera de la pantalla a la izquierda. Todo lo que pintemos después se pintará según este nuevo sistema de coordenadas.

Esto es exactamente lo que queremos. A medida que avanzamos en el juego, el héroe aumentará su coordenada X. Al mover el sistema de coordenadas hacia atrás, nos aseguramos de que se pinte dentro de la pantalla.

Stick-Hero.002
Una vez que usamos el comando translate, el centro del sistema de coordenadas se desplazará hacia la izquierda

Los comandos translate se acumulan. Esto significa que si llamamos al comando translate dos veces, el segundo no anula el primero, sino que agrega un desplazamiento encima del primer comando.

Vamos a llamar a la función draw en un bucle, por lo que es importante que restablezcamos esta transformación cada vez que dibujemos. Además, siempre comenzamos con la coordenada 0, 0 en la esquina superior izquierda. De lo contrario, el sistema de coordenadas se desplazaría hacia la izquierda infinitamente.

Podemos restaurar las transformaciones llamando al comando restaurar una vez que no queremos estar en este sistema de coordenadas desplazado. El comando restaurar restablece las transiciones y muchas otras configuraciones al estado en el que se encontraba el lienzo en el último comando guardar. Es por eso que a menudo comenzamos un bloque de pintura guardando el contexto y lo terminamos restaurándolo.

Cómo dibujar las plataformas

Entonces, eso fue solo la estructura, pero aún no hemos pintado nada. Comencemos con algo simple, dibujar plataformas. Los metadatos de las plataformas se almacenan en el array plataformas. Contiene la posición de inicio de la plataforma y su ancho.

Podemos iterar sobre este array y rellenar un rectángulo estableciendo la posición de inicio y el ancho y alto de la plataforma. Hacemos esto llamando a la función fillRect con las coordenadas X, Y y el ancho y alto del rectángulo a rellenar. Ten en cuenta que la coordenada Y está invertida, crece de arriba hacia abajo.

Stick-Hero.003
Dibujo de la plataforma
// Ejemplo de estado de las plataformaslet platforms = [ { x: 50, w: 50 }, { x: 90, w: 30 },];...function drawPlatforms() { platforms.forEach(({ x, w }) => { // Dibujar plataforma ctx.fillStyle = "black"; ctx.fillRect(x, canvasHeight - platformHeight, w, platformHeight); });}

Lo interesante de canvas, o al menos me sorprendió, es que una vez que pintas algo en el lienzo no puedes modificarlo. No es como si pintaras un rectángulo y luego pudieras cambiar su color. Una vez que algo está en el lienzo, se queda como está.

Al igual que con un lienzo real, una vez que has pintado algo puedes cubrirlo, pintando algo encima, o puedes intentar borrar el lienzo. Pero realmente no puedes cambiar partes existentes. Por eso configuramos el color aquí al principio y no después (con la propiedad fillStyle).

Cómo dibujar al héroe

No cubriremos la parte del héroe en detalle en este tutorial, pero puedes encontrar el código fuente de la demostración anterior en CodePen. Dibujar formas más avanzadas es un poco más complicado con el elemento del lienzo, y cubriré el dibujo con más detalle en un tutorial futuro.

Por ahora, simplemente usemos un rectángulo rojo como marcador de posición para el héroe. Nuevamente, usamos la función fillRect y pasamos una coordenada X, Y y el ancho y alto del héroe.

Las posiciones X e Y se basarán en el estado de heroX y heroY. La posición X del héroe es relativa al sistema de coordenadas, pero su posición Y es relativa a la parte superior de la plataforma (tiene un valor de 0 una vez en la parte superior de una plataforma). Necesitamos ajustar la posición Y para que esté en la parte superior de la plataforma.

function drawHero() {  const heroWidth = 20;  const heroHeight = 30;  ctx.fillStyle = "red";  ctx.fillRect(    heroX,    heroY + canvasHeight - platformHeight - heroHeight,    heroWidth,    heroHeight  );}

Cómo dibujar los palos

Entonces veamos cómo pintar los palos. Los palos son un poco más complicados porque pueden rotarse.

Los palos se almacenan en un arreglo de manera similar a las plataformas pero tienen atributos diferentes. Todos tienen una posición inicial, una longitud y una rotación. Los dos últimos cambios en el bucle principal del juego, y el primero, la posición, debe encajar en la esquina superior derecha de una plataforma.

Basados en la longitud y la rotación, podríamos usar algo de trigonometría y calcular la posición final del palo. Pero es mucho más interesante si transformamos el sistema de coordenadas nuevamente.

Podemos usar el comando translate nuevamente para establecer el centro del sistema de coordenadas en el borde de la plataforma. Luego podemos usar el comando rotate para rotar el sistema de coordenadas alrededor de este nuevo centro.

Stick-Hero.004
Después de usar los comandos translate y rotate, el sistema de coordenadas estará torcido alrededor de un nuevo centro
// Ejemplo de estado de los paloslet sticks = [  { x: 100, length: 50, rotation: 60 }];...function drawSticks() {  sticks.forEach((stick) => {    ctx.save();    // Mover el punto de anclaje al inicio del palo y rotar    ctx.translate(stick.x, canvasHeight - platformHeight);    ctx.rotate((Math.PI / 180) * stick.rotation);    // Dibujar el palo    ctx.lineWidth = 2;    ctx.beginPath();    ctx.moveTo(0, 0);    ctx.lineTo(0, -stick.length);    ctx.stroke();    // Restaurar transformaciones    ctx.restore();  });}

Después de los comandos translate y rotate, el punto de inicio del palo estará en la coordenada 0, 0 y el sistema de coordenadas estará rotado.

En este ejemplo, dibujamos una línea hacia arriba: tanto su inicio como su fin tienen la misma coordenada X. Solo cambia la coordenada Y. Sin embargo, la línea se dirige hacia la derecha porque todo el sistema de coordenadas se ha girado. Ahora hacia arriba es en dirección diagonal. Es un poco confuso, pero te puedes acostumbrar.

Stick-Hero.005
Mientras dibujamos una línea a lo largo del eje Y, la línea aparecerá diagonal debido al sistema de coordenadas transformado

El dibujo real de la línea también es interesante. No hay un comando simple de dibujo de líneas, por lo que tenemos que dibujar un camino.

Obtenemos un camino conectando varios puntos. Podemos conectarlos con arcos, curvas y líneas rectas. En este caso, tenemos uno muy simple. Simplemente comenzamos un camino (beginPath), nos movemos a una coordenada (moveTo), luego dibujamos una línea recta hasta la siguiente coordenada (lineTo). Luego lo finalizamos con el comando stroke.

También podemos terminar la ruta con el comando fill, pero eso solo tiene sentido con formas.

Ten en cuenta que debido a que desplazamos y giramos el sistema de coordenadas aquí nuevamente, al final de esta función debemos restaurar las transformaciones (y guardar la matriz de transformación al principio de esta función). De lo contrario, todos los comandos de dibujo futuros se torcerían como esto.

Manejo de eventos

Ahora que hemos dibujado la escena, comencemos el juego manejando las interacciones del usuario. Manejar eventos es la parte más fácil del juego. Estamos escuchando el evento mousedown y el evento mouseup, y manejamos el evento click del botón de reinicio.

Una vez que el usuario mantiene presionado el mouse, iniciamos la fase de estiramiento estableciendo la variable phase en stretching. Restablecemos la marca de tiempo que el bucle principal de eventos va a usar (hablaremos de esto más adelante), y activamos el bucle principal de eventos solicitando un cuadro de animación para la función animate.

Todo esto solo ocurre si el estado actual del juego es de espera. En cualquier otro caso, ignoramos el evento mousedown.

let phase = "waiting";let lastTimestamp;...const restartButton = document.getElementById("restart");...window.addEventListener("mousedown", function () {  if (phase == "waiting") {    phase = "stretching";    lastTimestamp = undefined;    window.requestAnimationFrame(animate);  }});window.addEventListener("mouseup", function () {  if (phase == "stretching") {    phase = "turning";  }});restartButton.addEventListener("click", function (event) {  resetGame();  restartButton.style.display = "none";});...

Manejar el evento mouseup es aún más sencillo. Si actualmente estamos estirando una barra, entonces detenemos eso y pasamos a la siguiente fase cuando la barra cae.

Finalmente, también agregamos un controlador de eventos para el botón de reinicio. El botón de reinicio está oculto de forma predeterminada y solo será visible una vez que el héroe haya caído. Pero ya podemos definir su comportamiento, y una vez que aparezca, funcionará. Si hacemos clic en reiniciar, llamamos a la función resetGame para reiniciar el juego y ocultamos el botón.

Esto es todo el manejo de eventos que tenemos. El resto depende del bucle principal de animación que acabamos de invocar con requestAnimationFrame.

El bucle principal de animación

El bucle principal es la parte más complicada del juego. Esta es una función que va a cambiar constantemente el estado del juego y llamará a la función draw para repintar toda la pantalla en función de este estado.

Dado que se va a llamar 60 veces por segundo, el repintado constante de la pantalla hará que parezca una animación continua. Debido a que esta función se ejecuta con tanta frecuencia, solo cambiamos el estado del juego poco a poco en cada llamada.

Esta función animate se desencadena como una llamada de requestAnimationFrame por el evento mousedown (ver arriba). Con su última línea, se invoca a sí misma hasta que la detenemos retornando de la función.

Solo hay dos casos en los que detendríamos el bucle: cuando pasamos a la fase waiting y no hay nada que animar, o cuando el héroe cae y el juego ha terminado.

Esta función lleva un seguimiento de cuánto tiempo ha pasado desde su última llamada. Vamos a usar esta información para calcular con precisión cómo debe cambiar el estado. Por ejemplo, cuando el héroe está caminando, necesitamos calcular exactamente cuántos píxeles se mueve en función de su velocidad y el tiempo transcurrido desde el último ciclo de animación.

let lastTimestamp;...function animate(timestamp) {  if (!lastTimestamp) {    // Primer ciclo    lastTimestamp = timestamp;    window.requestAnimationFrame(animate);    return;  }  let timePassed = timestamp - lastTimestamp;  switch (phase) {    case "waiting":      return; // Detener el bucle    case "stretching": {      sticks[sticks.length - 1].length += timePassed / stretchingSpeed;      break;    }    case "turning": {      sticks[sticks.length - 1].rotation += timePassed / turningSpeed;      ...      break;    }    case "walking": {      heroX += timePassed / walkingSpeed;      ...      break;    }    case "transitioning": {      sceneOffset += timePassed / transitioningSpeed;      ...      break;    }    case "falling": {      heroY += timePassed / fallingSpeed;      ...      break;    }  }    draw();  lastTimestamp = timestamp;  window.requestAnimationFrame(animate);}

Cómo Calcular el Tiempo Transcurrido Entre Dos Renders

Las funciones invocadas con la función requestAnimationFrame reciben el timestamp actual como atributo. Al final de cada ciclo, guardamos este valor de timestamp en el atributo lastTimestamp, de manera que en el siguiente ciclo podamos calcular cuánto tiempo ha pasado entre dos ciclos. En el código de arriba, esto es la variable timePassed.

El primer ciclo es una excepción porque en ese momento todavía no teníamos un ciclo anterior. Inicialmente, el valor de lastTimestamp es undefined. En este caso, omitimos un renderizado y solo renderizamos la escena en el segundo ciclo, donde ya tenemos todos los valores que necesitamos. Esta es la parte al principio de la función animate.

Cómo Animar Parte del Estado

En cada fase, animamos una parte diferente del estado. La única excepción es la fase de espera porque entonces no tenemos nada que animar. En ese caso, salimos de la función. Esto romperá el ciclo y la animación se detendrá.

En la fase de estiramiento, cuando el jugador mantiene presionado el ratón, necesitamos hacer crecer el palo a medida que pasa el tiempo. Calculamos cuánto más largo debe ser en función del tiempo transcurrido y un valor de velocidad que define cuánto tiempo se tarda en crecer un píxel.

Algo muy similar sucede en cada otra fase también. En la fase de giro, cambiamos la rotación del palo en función del tiempo transcurrido. En la fase de caminar, cambiamos la posición horizontal del héroe en función del tiempo. En la fase de transición, cambiamos el valor de desplazamiento de toda la escena. En la fase de caída, cambiamos la posición vertical del héroe.

Cada una de estas fases tiene su propia configuración de velocidad. Estos valores indican cuántos milisegundos se necesitan para hacer crecer el palo un píxel, girar el palo un grado, caminar un píxel, etc.

// Configuraciónconst stretchingSpeed = 4; // Milisegundos que se tarda en dibujar un píxelconst turningSpeed = 4; // Milisegundos que se tarda en girar un gradocriptconst walkingSpeed = 4;const transitioningSpeed = 2;const fallingSpeed = 2;...

Cómo Pasar a la Siguiente Fase

En la mayoría de estas fases, también tenemos un valor umbral que termina la fase y activa la siguiente. Las fases de espera y estiramiento son las excepciones porque su final se basa en la interacción del usuario. La fase de espera termina con el evento mousedown y la fase de estiramiento termina con el evento mouseup.

La fase de giro se detiene cuando el palo cae y su rotación alcanza los 90 grados. La fase de caminar termina cuando el héroe llega al borde de la siguiente plataforma o al final del palo. Y así sucesivamente.

Si se alcanzan estos umbrales, el bucle principal del juego establece el siguiente fase y, en el siguiente bucle, actuará en consecuencia. Veamos esto con más detalle.

La fase de espera

Si estamos en la fase de espera y no está sucediendo nada, salimos de la función. Esta instrucción de retorno significa que nunca llegamos al final de la función y no habrá otra solicitud de un fotograma de animación. El ciclo se detiene. Necesitamos que el controlador de entrada del usuario active otro ciclo.

Stick-Hero.001-1
En la fase de espera no sucede nada
function animate(timestamp) {	...  switch (phase) {    case "waiting":      return; // Detener el ciclo    ...  }	...}

La fase de estiramiento

En la fase de estiramiento, aumentamos la longitud del último palo en función del tiempo transcurrido y esperamos hasta que el usuario suelte el ratón. El último palo siempre es el que está frente al héroe. Después de cada transición de vista, se agrega un nuevo palo a la plataforma actual.

Stick-Hero.002-1
En la fase de estiramiento aumentamos la longitud del último palo
function animate(timestamp) {	...  switch (phase) {		...    case "stretching": {      sticks[sticks.length - 1].length += timePassed / stretchingSpeed;			break;    }		...  }  ...}

La fase de giro

En la fase de giro, cambiamos la rotación del último palo. Solo lo hacemos hasta que el palo alcance los 90 grados, porque eso significa que el palo ha alcanzado una posición plana. Luego establecemos la fase de caminar, para que el próximo requestAnimationFrame ajuste al héroe y no al palo.

Stick-Hero.003-1
En la fase de giro aumentamos la rotación del último palo

Una vez que el palo alcanza los 90 grados, si el palo cae en la siguiente plataforma, también aumentamos el valor de puntuación. Incrementamos el estado de score y actualizamos el atributo innerText del scoreElement (ver el esquema del capítulo del archivo JavaScript). Luego generamos una nueva plataforma para asegurarnos de que nunca nos quedemos sin ellas.

Si el palo no cae en la siguiente plataforma, no aumentamos la puntuación ni generamos una nueva plataforma. Tampoco activamos aún la fase de caída, porque primero el héroe todavía intenta caminar a lo largo del palo.

function animate(timestamp) {  ...  switch (phase) {    ...    case "turning": {      sticks[sticks.length - 1].rotation += timePassed / turningSpeed;      if (sticks[sticks.length - 1].rotation >= 90) {        sticks[sticks.length - 1].rotation = 90;        const nextPlatform = thePlatformTheStickHits();        if (nextPlatform) {          score++;          scoreElement.innerText = score;          generatePlatform();        }        phase = "walking";      }      break;    }    ...  }  ...}

Esta fase utiliza una función auxiliar para determinar si el palo caerá sobre la plataforma o no. Calcula la posición del extremo derecho del último palo y comprueba si esta posición cae entre el borde izquierdo y derecho de una plataforma. Si lo hace, devuelve la plataforma, de lo contrario devuelve indefinido.

function thePlatformTheStickHits() {  const lastStick = sticks[sticks.length - 1];  const stickFarX = lastStick.x + lastStick.length;  const platformTheStickHits = platforms.find(    (platform) => platform.x < stickFarX && stickFarX < platform.x + platform.w  );  return platformTheStickHits;}

La fase de caminar

En la fase de caminar, movemos al héroe hacia adelante. El final de esta fase depende de si el palo alcanza la siguiente plataforma o no. Para determinarlo, usamos la misma función auxiliar que acabamos de definir.

Stick-Hero.004-1
En la fase de caminar aumentamos la posición X del héroe

Si el final del palo cae sobre una plataforma, entonces limitamos la posición del héroe al borde de esa plataforma. Una vez que se haya alcanzado, pasamos a la fase de transición. Si el final del palo no cae sobre una plataforma, sin embargo, limitamos el movimiento hacia adelante del héroe hasta el final del palo y luego comenzamos la fase de caída.

function animate(timestamp) {  ...  switch (phase) {    ...    case "walking": {      heroX += timePassed / walkingSpeed;      const nextPlatform = thePlatformTheStickHits();      if (nextPlatform) {        // Si el héroe alcanzará otra plataforma, entonces limitamos su posición en su borde        const maxHeroX = nextPlatform.x + nextPlatform.w - 30;        if (heroX > maxHeroX) {          heroX = maxHeroX;          phase = "transitioning";        }      } else {        // Si el héroe no alcanzará otra plataforma, entonces limitamos su posición en el extremo del palo        const maxHeroX =          sticks[sticks.length - 1].x +          sticks[sticks.length - 1].length;        if (heroX > maxHeroX) {          heroX = maxHeroX;          phase = "falling";        }      }      break;    }    ...  }  ...}

La fase de transición

En la fase de transición, movemos toda la escena. Queremos que el héroe se mantenga en la misma posición en la pantalla donde inicialmente estaba, pero ahora está parado en una plataforma diferente. Esto significa que tenemos que calcular cuánto debemos desplazar toda la escena hacia atrás para lograr la misma posición. Luego simplemente establecemos la fase en espera y esperamos otro evento del mouse.

Stick-Hero.005-1
En la fase de transición desplazamos toda la vista
function animate(timestamp) {	...  switch (phase) {		...    case "transitioning": {      sceneOffset += timePassed / transitioningSpeed;      const nextPlatform = thePlatformTheStickHits();      if (nextPlatform.x + nextPlatform.w - sceneOffset < 100) {        sticks.push({          x: nextPlatform.x + nextPlatform.w,          length: 0,          rotation: 0,        });        phase = "waiting";      }      break;    }		...  }  ...}

Sabemos que hemos alcanzado la posición correcta cuando el lado derecho de la plataforma – desplazado por el offset – alcanza la posición original del lado derecho de la primera plataforma. Si volvemos a mirar cómo se inicializa la plataforma, vemos que la primera plataforma siempre tiene una posición X de 50 y su ancho también es siempre 50. Esto significa que su extremo derecho estará en 100.

Al final de esta fase, también agregamos un nuevo palo al arreglo de palos con valores iniciales.

La fase de caída

En el escenario de caída, dos cosas están cambiando: la posición del héroe y la rotación del último palo. Luego, una vez que el héroe ha caído fuera de la pantalla, detenemos el ciclo del juego nuevamente al salir de la función.

Stick-Hero.006
En la fase de caída aumentamos tanto la posición Y del héroe como la rotación del último palo
function animate(timestamp) {	...  switch (phase) {		...    case "falling": {      heroY += timePassed / fallingSpeed;      if (sticks[sticks.length - 1].rotation < 180) {        sticks[sticks.length - 1].rotation += timePassed / turningSpeed;      }      const maxHeroY = platformHeight + 100;      if (heroY > maxHeroY) {        restartButton.style.display = "block";        return;      }      break;    }		...  }  ...}

Así es como funciona el bucle principal: cómo el juego pasa de una fase a otra, cambiando una serie de variables. Al final de cada ciclo, la función llama a la función draw para actualizar la escena y solicita otro fotograma. ¡Si hiciste todo correctamente, deberías tener un juego funcional ahora mismo!

Resumen

En este tutorial, hemos cubierto mucho. Aprendimos cómo pintar formas básicas en un elemento canvas con JavaScript e implementamos un juego completo.

A pesar de la longitud de este artículo, todavía hay algunas cosas que no cubrimos aquí. Puedes consultar el código fuente de este juego para ver características adicionales en CodePen. Estas incluyen:

  • Cómo hacer que el juego se ajuste a toda la ventana del navegador y traducir la pantalla en consecuencia.
  • Cómo dibujar un fondo en la escena y cómo dibujar una versión más detallada de nuestro héroe.
  • Agregar una zona de puntuación doble en el medio de cada plataforma. Si el extremo del palo cae en esta región muy pequeña, el héroe obtiene dos puntos.

Espero que hayas disfrutado de este tutorial. Mantente atento a más contenido aquí en CodesCode y en mi canal de YouTube.

Suscríbete para más tutoriales sobre Desarrollo Web:

Juego de desarrollo con JavaScript, tutoriales de programación creativa, HTML canvas, SVG, Three.js, y algo de React y Vue https://twitter.com/HunorBorbelyhttps://codepen.io/HunorMarton…favicon_144x144YouTubeAPkrFKaQ34YAITK6J0qgy6Iv6pms35dPhF68Hyy7BoYoLA=s900-c-k-c0x00ffffff-no-rj

Leave a Reply

Your email address will not be published. Required fields are marked *