Artículo original escrito por: Matt Sokola
Artículo original: React Tutorial – How to Build the 2048 Game in React
Traducido y adaptado por: Juan C. Guaña

Hoy aprenderás a construir tu propio clon del juego 2048 en React.

Lo que hace que este artículo sea único es que nos centraremos en crear divertidas animaciones. Aparte de React, usaremos TypeScript y haremos algunas transiciones CSS usando LESS.

Solo usaremos interfaces de React modernas como hooks y la API context.

Este artículo contiene algunos recursos externos como:

  • Juego 2048 (GitHub Pages)
  • Ejemplos de animación para 2048 (GitHub Pages)
  • Código fuente (GitHub)
  • ... y un video de YouTube. Me tomó más de un mes preparar este tutorial, por lo que significaría muchísimo para mí si lo miras, presionas el botón Me gusta y te suscribes a mi canal.

¡Gracias!

Reglas del juego 2048

En este juego, el jugador debe combinar fichas que contengan los mismos números hasta alcanzar el número 2048. Las fichas solo pueden contener valores enteros a partir de 2, y que sean una potencia de dos, como 2, 4, 8, 16, 32, etc.

Idealmente, el jugador debería alcanzar la ficha 2048 en el menor número de pasos. El tablero tiene una dimensión de 4 x 4 azulejos, por lo que puede caber hasta 16 azulejos. Si el tablero está lleno y no hay movimiento posible para hacer como unir fichas, el juego termina.

giphy

Mientras creaba este tutorial, tomé atajos para enfocarme en la mecánica y las animaciones del juego. ¿De qué me deshice?

  • En nuestro ejemplo, el juego siempre crea una nueva ficha con el número 2. Pero en la versión propia debería generar un número aleatorio (2 o 4, para que sea más difícil jugar).
  • Además, no manejaremos las victorias y las derrotas. Puedes jugar después de completar el número 2048, y no pasa nada cuando el tablero no se puede resolver; debes hacer clic en el botón de reinicio.
  • Me salté la puntuación.

Si lo deseas, puedes implementar esas funciones que faltan por tu cuenta. Simplemente haz un fork de mi repositorio e impleméntelo en tu propia configuración.

Estructura del proyecto

La aplicación contiene los siguientes elementos:

  • Board (componente) – responsable de renderizar mosaicos. Expone un hook llamado useBoard.
  • Grid (componente) – renderiza una cuadrícula de 4x4.
  • Tile (componente) – responsable de todas las animaciones relacionadas con el mosaico y de renderizar el mosaico en sí.
  • Game (componente) – combina todos los elementos anteriores juntos. Incluye el hook useGame que es responsable de hacer cumplir las reglas y restricciones del juego.

Cómo construir el componente Tile

Queremos invertir más tiempo en animaciones, así que comenzaré la historia desde el componente Tile. Al final, este componente es responsable de todas las animaciones del juego.

Solo hay dos animaciones bastante simples en 2048 – resaltar mosaicos y deslizarlos por el tablero. Podemos manejar esas animaciones con transiciones CSS declarando los siguientes estilos:

.tile {
  // ...
  transition-property: transform;
  transition-duration: 100ms;
  transform: scale(1);
}

En el momento actual, definí solo una transición que resaltará el mosaico cuando se crea o se fusiona. Lo dejaremos así por ahora.

Consideremos cómo se supone que deben verse los metadatos de Tile, para que podamos usarlos fácilmente. Decidí llamarlo TileMeta, ya que no queremos que el nombre entre en conflicto con otras entidades como el componente Tile:

type TileMeta = {
  id: number;
  position: [number, number];
  value: number;
  mergeWith?: number;
};
  • id – el identificador único del mosaico. Es importante para que React DOM no vuelva a renderizar todos los mosaicos desde cero en cada cambio. De lo contrario, veríamos todos los mosaicos resaltados en cada acción del jugador.
  • position – la posición de la ficha en el tablero. Es un arreglo con dos elementos, la coordenada x e y (los valores posibles son 0 - 3 en ambos casos).
  • value – el valor del mosaico. Solo potencia de dos, a partir de 2.
  • mergeWith – (opcional) la id del mosaico que va a absorber el mosaico actual. Si está presente, el mosaico debe fusionarse con otro mosaico y desaparecer.

Cómo crear y fusionar mosaicos

Queremos resaltar de alguna manera que el mosaico cambió después de la acción del jugador. Creo que la mejor manera sería cambiar la escala del mosaico para indicar que se ha creado un nuevo mosaico o se ha cambiado uno.

export const Tile = ({ value, position }: Props) => {
  const [scale, setScale] = useState(1);

  const prevValue = usePrevProps<number>(value);

  const isNew = prevCoords === undefined;
  const hasChanged = prevValue !== value;
  const shallAnimate = isNew || hasChanged;

  useEffect(() => {
    if (shallAnimate) {
      setScale(1.1);
      setTimeout(() => setScale(1), 100);
    }
  }, [shallAnimate, scale]);

  const style = {
    transform: `scale(${scale})`,
  };

  return (
    <div className={`tile tile-${value}`} style={style}>
      {value}
    </div>
  );
};

Para activar la animación, debemos considerar dos casos:

  • un nuevo mosaico – el valor anterior será null.
  • el mosaico cambió el valor – el valor anterior será diferente al actual.

Y el resultado es el siguiente:

giphy--1-

Es posible que hayas notado que estoy usando un hook personalizado llamado usePrevProps. Éste ayuda a rastrear los valores anteriores de las propiedades del componente (props).

Podría usar referencias para recuperar los valores anteriores, pero desordenaría mi componente. Decidí extraerlo en un hook independiente, para que el código sea legible y pueda usar este hook en otros lugares.

Si deseas usarlo en tu proyecto, simplemente copia este fragmento:

import { useEffect, useRef } from "react";

/**
 * `usePrevProps` stores the previous value of the prop.
 *
 * @param {K} value
 * @returns {K | undefined}
 */
export const usePrevProps = <K = any>(value: K) => {
  const ref = useRef<K>();

  useEffect(() => {
    ref.current = value;
  });

  return ref.current;
};

Cómo deslizar baldosas por el tablero

El juego se verá chiflado sin el deslizamiento animado de las fichas por el tablero. Podemos crear fácilmente esta animación usando transiciones CSS.

Lo más conveniente será utilizar propiedades responsables del posicionamiento, como left y top. Así que necesitamos modificar nuestros estilos CSS para que se vean así:

.tile {
  position: absolute;
  // ...
  transition-property: left, top, transform;
  transition-duration: 250ms, 250ms, 100ms;
  transform: scale(1);
}

Una vez que hemos declarado los estilos, podemos implementar la lógica responsable de cambiar la posición de una ficha en el tablero.

export const Tile = ({ value, position, zIndex }: Props) => {
  const [boardWidthInPixels, tileCount] = useBoard();
  // ...

  useEffect(() => {
    // ...
  }, [shallAnimate, scale]);

  const positionToPixels = (position: number) => {
    return (position / tileCount) * (boardWidthInPixels as number);
  };

  const style = {
    top: positionToPixels(position[1]),
    left: positionToPixels(position[0]),
    transform: `scale(${scale})`,
    zIndex,
  };

  // ...
};

Como puedes ver, la ecuación en la función positionToPixels necesita conocer la posición del mosaico, la cantidad total de mosaicos por fila y columna, y la longitud total del tablero en píxeles (ancho o alto,  igual que – es un cuadrado). El valor calculado se transmite al elemento HTML como un estilo en línea.

Espere un minuto ... pero ¿qué pasa con el hook useBoard y la propiedad zIndex?

  • useBoard nos permite acceder a las propiedades de la placa dentro de los componentes secundarios sin pasarlos. El componente Tile necesita conocer el ancho y el recuento total de mosaicos para encontrar el lugar correcto en el tablero. Gracias a React Context API podemos compartir propiedades en múltiples capas de componentes sin contaminar sus propiedades (accesorios).
  • zIndex es una propiedad de CSS que define el orden de los mosaicos en la pila. En nuestro caso es el id del mosaico. Como puedes ver en el gif a continuación, los mosaicos se pueden apilar unos sobre otros, por lo que zIndex nos permitió especificar cuál estará en la parte superior.
a

Cómo construir el tablero

Otra parte importante del juego es el tablero. El componente Board es responsable de renderizar la cuadrícula y los mosaicos.

Parece que el tablero ha duplicado la lógica de negocio con el componente Tile, pero hay una pequeña diferencia. El tablero contiene información sobre su tamaño (ancho y alto) y el número de columnas y filas. Es lo opuesto al Tile que solo conoce su propia posición.

type Props = {
  tiles: TileMeta[];
  tileCountPerRow: number;
};

const Board = ({ tiles, tileCountPerRow = 4 }: Props) => {
  const containerWidth = tileTotalWidth * tileCountPerRow;
  const boardWidth = containerWidth + boardMargin;

  const tileList = tiles.map(({ id, ...restProps }) => (
    <Tile key={`tile-${id}`} {...restProps} zIndex={id} />
  ));

  return (
    <div className="board" style={{ width: boardWidth }}>
      <BoardProvider containerWidth={containerWidth} tileCountPerRow={tileCountPerRow}>
        <div className="tile-container">{tileList}</div>
        <Grid />
      </BoardProvider>
    </div>
  );
};

El componente Board utiliza BoardProvider para distribuir el ancho del contenedor de mosaicos y la cantidad de mosaicos por fila y columna a todos los mosaicos y el componente de cuadrícula.

const BoardContext = React.createContext({
  containerWidth: 0,
  tileCountPerRow: 4,
});

type Props = {
  containerWidth: number;
  tileCountPerRow: number;
  children: any;
};

const BoardProvider = ({
  children,
  containerWidth = 0,
  tileCountPerRow = 4,
}: Props) => {
  return (
    <BoardContext.Provider value={{ containerWidth, tileCountPerRow }}>
      {children}
    </BoardContext.Provider>
  );
};

BoardProvider usa la API React Context para propagar propiedades a cada niño. Si algún componente necesita usar cualquier valor disponible en el proveedor, puede recuperarlo llamando al hook useBoard

Voy a omitir este tema, ya que hablé más sobre él en mi video sobre Feature Toggles en React. Si deseas obtener más información sobre ellos, puedes verlo.

const useBoard = () => {
  const { containerWidth, tileCount } = useContext(BoardContext);

  return [containerWidth, tileCount] as [number, number];
};

Cómo construir el componente del juego

Ahora podemos especificar las reglas del juego y exponer la interfaz para jugar. Voy a comenzar con la navegación, ya que te ayudará a comprender por qué la lógica del juego se implementa de esa manera.

import { useThrottledCallback } from "use-debounce";

const Game = () => {
  const [tiles, moveLeft, moveRight, moveUp, moveDown] = useGame();

  const handleKeyDown = (e: KeyboardEvent) => {
  	// disables page scrolling with keyboard arrows
    e.preventDefault();
  
    switch (e.code) {
      case "ArrowLeft":
        moveLeft();
        break;
      case "ArrowRight":
        moveRight();
        break;
      case "ArrowUp":
        moveUp();
        break;
      case "ArrowDown":
        moveDown();
        break;
    }
  };

  // protects the reducer from being flooded with events.
  const throttledHandleKeyDown = useThrottledCallback(
    handleKeyDown,
    animationDuration,
    { leading: true, trailing: false }
  );

  useEffect(() => {
    window.addEventListener("keydown", throttledHandleKeyDown);

    return () => {
      window.removeEventListener("keydown", throttledHandleKeyDown);
    };
  }, [throttledHandleKeyDown]);

  return <Board tiles={tiles} tileCountPerRow={4} />;
};

Como puedes ver, la lógica del juego será manejada por el hook useGame que expone las siguientes propiedades y métodos:

  • tiles – una variedad de fichas disponibles en el tablero. Utiliza el tipo TileMeta descrito anteriormente.
  • moveLeft – una función que desliza todas las fichas hacia el lado izquierdo del tablero.
  • moveRight – una función que desliza todas las fichas hacia el lado derecho del tablero.
  • moveUp – una función que desliza todas las fichas a la parte superior del tablero.
  • moveDown – una función que desliza todas las fichas al fondo del tablero.

Usamos la devolución de llamada throttledHandleKeyDown para evitar que los jugadores inunden el juego con toneladas de movimientos al mismo tiempo. Básicamente, el jugador debe esperar hasta que se complete la animación antes de poder activar otro movimiento.

Este mecanismo se llama estrangulamiento (throttling). Decidí usar el hook useThrottledCallback del paquete use-debounce.

Cómo utilizar el hook useGame

Mencioné anteriormente que el componente Game también manejará las reglas del juego. Vamos a extraer la lógica del juego en un hook en lugar de escribirla directamente en el componente (ya que no queremos saturar el código).

El hook useGame se basa en el hook useReducer, que es un hook incorporado dentro de React. Comenzaré definiendo la forma del estado del reducer.

type TileMap = { 
  [id: number]: TileMeta;
}

type State = {
  tiles: TileMap;
  inMotion: boolean;
  hasChanged: boolean;
  byIds: number[];
};

El estado contiene los siguientes campos:

  • tiles – una tabla hash responsable de almacenar mosaicos. La tabla hash facilita la búsqueda de entradas por sus claves, por lo que es una combinación perfecta para nosotros, ya que queremos encontrar mosaicos por sus ids.
  • byIds – un arreglo que contiene todos los ids en el orden esperado (es decir, ascendente). Debemos mantener el orden correcto de los mosaicos, para que React no vuelva a renderizar todo el tablero cada vez que cambiemos el estado.
  • hasChange – realiza un seguimiento de los cambios de mosaico. Si nada ha cambiado, no se generará el nuevo mosaico.
  • inMotion – determina si las fichas todavía se mueven. Si es así, el nuevo mosaico no se generará hasta que se complete el movimiento.

Actions

useReducer requiere especificar las actions que son compatibles con este reducer.

type Action =
  | { type: "CREATE_TILE"; tile: TileMeta }
  | { type: "UPDATE_TILE"; tile: TileMeta }
  | { type: "MERGE_TILE"; source: TileMeta; destination: TileMeta }
  | { type: "START_MOVE" }
  | { type: "END_MOVE" };

¿De qué son responsables esas actions?

  • CREATE_TILE – crea un nuevo mosaico y lo agrega a la tabla hash de tiles (mosaicos). Cambia el indicador hasChange a false ya que esta acción siempre se activa cuando se agrega una nueva ficha al tablero.
  • UPDATE_TILE – actualiza un mosaico existente. No modifica la identificación, lo cual es importante para que las animaciones sigan funcionando. Lo usaremos para reposicionar el mosaico y cambiar su valor (durante las fusiones). Además, cambia el indicador hasChange a true.
  • MERGE_TILE – fusiona un mosaico de origen en un mosaico de destino. Después de esta operación, el mosaico de destino cambiará su valor (se agregará el valor del mosaico de origen). Y eliminará el mosaico de origen de la tabla de tiles y el arreglo byIds.
  • START_MOVE – le dice al reducer que debe esperar múltiples actions, por lo que debe esperar hasta que todas las animaciones estén completas antes de poder generar un nuevo mosaico.
  • END_MOVE – le dice al reducer que se completaron todas las acciones y que puede crear de forma segura un nuevo mosaico.

Si lo deseas, puedes escribir la lógica de este reducer por su cuenta o copiar la mía:

type TileMap = { 
  [id: number]: TileMeta;
}

type State = {
  tiles: TileMap;
  inMotion: boolean;
  hasChanged: boolean;
  byIds: number[];
};

type Action =
  | { type: "CREATE_TILE"; tile: TileMeta }
  | { type: "UPDATE_TILE"; tile: TileMeta }
  | { type: "MERGE_TILE"; source: TileMeta; destination: TileMeta }
  | { type: "START_MOVE" }
  | { type: "END_MOVE" };

const initialState: State = {
  tiles: {},
  byIds: [],
  hasChanged: false,
  inMotion: false,
};

const GameReducer = (state: State, action: Action) => {
  switch (action.type) {
    case "CREATE_TILE":
      return {
        ...state,
        tiles: {
          ...state.tiles,
          [action.tile.id]: action.tile,
        },
        byIds: [...state.byIds, action.tile.id],
        hasChanged: false,
      };
    case "UPDATE_TILE":
      return {
        ...state,
        tiles: {
          ...state.tiles,
          [action.tile.id]: action.tile,
        },
        hasChanged: true,
      };
    case "MERGE_TILE":
      const {
        [action.source.id]: source,
        [action.destination.id]: destination,
        ...restTiles
      } = state.tiles;
      return {
        ...state,
        tiles: {
          ...restTiles,
          [action.destination.id]: {
            id: action.destination.id,
            value: action.source.value + action.destination.value,
            position: action.destination.position,
          },
        },
        byIds: state.byIds.filter((id) => id !== action.source.id),
        hasChanged: true,
      };
    case "START_MOVE":
      return {
        ...state,
        inMotion: true,
      };
    case "END_MOVE":
      return {
        ...state,
        inMotion: false,
      };
    default:
      return state;
  }
};

Si no comprendes por qué definimos esas actions, no te preocupes, ahora vamos a implementar un hook que, con suerte, arrojará algo de luz al respecto.

Cómo implementar el Hook

Veamos la función responsable de los movimientos de un jugador. Nos centraremos en el movimiento a la izquierda solo ya que los demás son casi iguales.

  const moveLeftFactory = () => {
    const retrieveTileIdsByRow = (rowIndex: number) => {
      const tileMap = retrieveTileMap();

      const tileIdsInRow = [
        tileMap[tileIndex * tileCount + 0],
        tileMap[tileIndex * tileCount + 1],
        tileMap[tileIndex * tileCount + 2],
        tileMap[tileIndex * tileCount + 3],
      ];

      const nonEmptyTiles = tileIdsInRow.filter((id) => id !== 0);
      return nonEmptyTiles;
    };

    const calculateFirstFreeIndex = (
      tileIndex: number,
      tileInRowIndex: number,
      mergedCount: number,
      _: number
    ) => {
      return tileIndex * tileCount + tileInRowIndex - mergedCount;
    };

    return move.bind(this, retrieveTileIdsByRow, calculateFirstFreeIndex);
  };
  
  const moveLeft = moveLeftFactory();

Como puedes ver, decidí vincular dos callbacks a la función move. Esta técnica se llama inversión de control, por lo que el consumidor de la función podrá inyectar sus propios valores en la función ejecutada.

Si no sabes cómo funciona bind, debes aprender sobre él porque es una pregunta muy común en las entrevistas de trabajo.

El primer callback llamado retrieveTileIdsByRow es responsable de encontrar todos los mosaicos no vacíos disponibles en una fila (para movimientos horizontales – izquierda o derecha). Si el jugador hace los movimientos verticales (arriba o abajo), buscaremos todas las fichas en una columna.

El segunda callback llamado calculateFirstFreeIndex encuentra la posición más cercana al borde del tablero en función de los parámetros dados, como el índice de mosaico, el índice del mosaico en fila o columna, el número de mosaicos combinados y el índice máximo posible.

Ahora veremos la lógica de negocio de la función move. Expliqué el código de esta función en los comentarios. El algoritmo puede ser un poco complejo, y creo que será más fácil de entender si documente el código línea por línea:

  type RetrieveTileIdsByRowOrColumnCallback = (tileIndex: number) => number[];

  type CalculateTileIndex = (
    tileIndex: number,
    tileInRowIndex: number,
    mergedCount: number,
    maxIndexInRow: number
  ) => number;

  const move = (
    retrieveTileIdsByRowOrColumn: RetrieveTileIdsByRowOrColumnCallback,
    calculateFirstFreeIndex: CalculateTileIndex
  ) => {
    // no se pueden crear nuevos mosaicos durante el movimiento.
    dispatch({ type: "START_MOVE" });

    const maxIndex = tileCount - 1;

    // itera a través de cada fila o columna (depende del tipo de movimiento - vertical u horizontal).
    for (let tileIndex = 0; tileIndex < tileCount; tileIndex += 1) {
      // recupera mosaicos en la fila o columna.
      const availableTileIds = retrieveTileIdsByRowOrColumn(tileIndex);

      // previousTile se usa para determinar si el mosaico se puede fusionar con el mosaico actual.
      let previousTile: TileMeta | undefined;
      // mergeCount ayuda a llenar los huecos creados por las combinaciones de mosaicos: dos mosaicos se convierten en uno.
      let mergedTilesCount = 0;

      // interate a través de los mosaicos disponibles.
      availableTileIds.forEach((tileId, nonEmptyTileIndex) => {
        const currentTile = tiles[tileId];

        // si el mosaico anterior tiene el mismo valor que el actual, deben fusionarse.
        if (
          previousTile !== undefined &&
          previousTile.value === currentTile.value
        ) {
          const tile = {
            ...currentTile,
            position: previousTile.position,
            mergeWith: previousTile.id,
          } as TileMeta;

          // retrasa la fusión en 250ms, por lo que la animación deslizante se puede completar.
          throttledMergeTile(tile, previousTile);
          // El mosaico anterior debe borrarse, ya que un solo mosaico puede fusionarse solo una vez por movimiento.
          previousTile = undefined;
          // incrementar el contador combinado a la posición correcta para que las fichas consecutivas eliminen los huecos.
          mergedTilesCount += 1;

          return updateTile(tile);
        }

        // else - los mosaicos anteriores y actuales son diferentes - mueva el mosaico al primer espacio libre.
        const tile = {
          ...currentTile,
          position: indexToPosition(
            calculateFirstFreeIndex(
              tileIndex,
              nonEmptyTileIndex,
              mergedTilesCount,
              maxIndex
            )
          ),
        } as TileMeta;

        // el mosaico anterior se convierte en el mosaico actual para comprobar si el siguiente mosaico se puede fusionar con este.
        previousTile = tile;

        // solo si el mosaico ha cambiado de posición, se actualizará
        if (didTileMove(currentTile, tile)) {
          return updateTile(tile);
        }
      });
    }

    // espere hasta el final de todas las animaciones.
    setTimeout(() => dispatch({ type: "END_MOVE" }), animationDuration);
  };

El código completo de este hook tiene más de 400 líneas de código, así que en lugar de pegarlo aquí, decidí mantenerlo en GitHub, así que revisa el código completo allí.

Tarea

Mencioné anteriormente que faltan algunas características. Si deseas comprender el código en profundidad, puedes bifurcar mi repositorio e implementar las siguientes características:

  • Puntuación – puedes definir tu propio algoritmo.
  • Soporte de victorias y pérdidas
  • Para la generación de mosaicos nuevos, elije un valor de mosaico aleatorio – ya sea 2 o 4. El 4 no debe aparecer menos del 5% de las veces.

Si deseas que revise tu código, puedes invitarme a tu solicitud de pull request en GitHub – mi nombre de usuario es mateuszsokola. Tal vez grabe un video sobre cómo reviso tu código.

Resumen

Espero que hayas disfrutado de mi tutorial. Esta vez decidí centrarme en la esencia del tema en lugar de construir React y CSS básicos, así que me salté esas partes básicas. Creo que hace que este artículo sea más fácil de digerir.

¿Algún comentario o pregunta? ¡Grítame en twitter!

Si este artículo te resultó útil, compártalo para que más desarrolladores puedan aprender de él. De vez en cuando publico videos en mi canal de YouTube, y sería genial si te suscribes a mi canal, presionas el botón me gusta y dejas un comentario debajo de tu video favorito.

¡Manténganse al tanto!