[Перевод] Как сделать игру 2048 на React

image-loader.svg

Автор этого туториала сосредоточился на анимации. Он использовал хуки библиотеки React, её Context API, а также TypeScript и LESS. В конце вы найдёте ссылки на игру, её код и демо анимаций. Подробности рассказываем под катом, пока у нас начинается курс по Frontend-разработке.

Правила игры 2048

Числа на плитках — только степени двойки, начиная с самой 2. Игрок объединяет плитки с одинаковыми числами. Числа суммируются, пока дело не дойдёт до 2048. Игрок должен добраться до плитки с числом 2048 за наименьшее количество шагов.

Если доска заполнена и нет возможности сделать ход, например объединить плитки вместе, — игра окончена.

image-loader.svg

Для целей статьи я сосредоточился на игровой механике и анимации и пренебрёг деталями:

  • Число на новой плитке всегда 2, а в полной версии игры оно случайно.

  • Играть можно и после 2048, а если ходов на доске не осталось, то не произойдёт ничего. Чтобы начать сначала, нажмите кнопку сброса.

  • И последнее: очки не подсчитываются.

Структура проекта

Приложение состоит из этих компонентов React:

  • Board отвечает за рендеринг плиток. Использует один хук под названием  useBoard.

  • Grid рендерит сетку 4×4.

  • Tile отвечает за все связанные с плиткой анимации и рендеринг самой плитки.

  • Game объединяет все элементы выше и включает хук  useGame, отвечающий за выполнение правил и ограничений игры.

Как сделать компонент плитки

В этом проекте больше времени хочется уделить анимации, поэтому я начинаю рассказ с компонента Tile. Именно он отвечает за все анимации. В 2048 есть две простых анимации — выделение плитки и её перемещение по доске. Написать их мы можем при помощи CSS-переходов:

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

Я определил только один переход, выделяющий плитку, когда она создаётся или объединяется с другой плиткой. Пока оставим его таким.

Посмотрим, как должны выглядеть метаданные Tile, чтобы легко с ними работать. Я решил назвать тип метаданных  TileMeta: не хочется, чтобы его имя конфликтовало с другими, например Tile:

type TileMeta = {
  id: number;
  position: [number, number];
  value: number;
  mergeWith?: number;
};
  • id — уникальный идентификатор плитки. Он нужен, чтобы DOM React при каждом изменении не перерисовывал все плитки с самого начала. Иначе мы увидим подсвечивание плиток на каждом действии игрока.

  • position — положение плитки на доске. Это массив с двумя элементами, то есть координатами  x и y и значениями от 0 до 3.

  • value — число на плитке.

  • mergeWith — необязательный идентификатор плитки, которая поглотит текущую. Если он существует, то плитка должна слиться с другой плиткой и исчезнуть.

Как создавать и объединять плитки

Как-то нужно отметить, что плитка изменилась после действия игрока. Думаю, лучший способ — изменить масштаб плитки. Изменение масштаба покажет, что была создана новая плитка или изменена другая:

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

  const prevValue = usePrevProps(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 (
    
{value}
); };

Чтобы запустить анимацию, нужно рассмотреть два случая:

  • создаётся новая плитка — предыдущее значение будет равно null;

  • плитка изменяет значение — предыдущее значение будет отличаться от текущего.

И вот результат:

image-loader.svg

Вы могли заметить, что я работаю с пользовательским хуком usePrevProps. Он помогает отслеживать предыдущие значения свойств компонента (props).

Я мог бы использовать ссылки, но они громоздкие, поэтому решил выделить код в отдельный хук ради читабельности и для того, чтобы использовать хук повторно. Если вы хотите задействовать его в своём проекте, просто скопируйте фрагмент ниже:

import { useEffect, useRef } from "react";

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

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

  return ref.current;
};

Как двигать плитки по доске

Без анимированного движения плиток по доске игра будет смотреться неаккуратно. Такую анимацию легко создать при помощи CSS-переходов. И удобнее всего будет воспользоваться свойствами позиционирования, например left и top. Изменим CSS таким образом:

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

Объявив стили, можно написать логику изменения положения плитки:

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,
  };

  // ...
};

Как видите, выражение в positionToPixels должно знать положение плитки, общее количество плиток в строке и столбце, а ещё общую длину доски в пикселях. Вычисленное значение передаётся в элемент HTML как встроенный стиль. Но как же хук  useBoard и свойство  zIndex?

  • Свойство useBoard позволяет получить доступ к свойствам доски внутри дочерних компонентов, не передавая их ниже. Чтобы найти нужное место на доске, компоненту Tile нужно знать ширину и общее количество плиток. Благодаря React Context API мы можем обмениваться свойствами между несколькими слоями компонентов, не загрязняя их свойства (props).

  •  zIndex — это свойство CSS, которое определяет порядок расположения плиток. В нашем случае это id плитки. На рисунке ниже видно, что плитки могут укладываться друг на друга. Свойство zIndex позволяет указать, какая плитка находится наверху.

image-loader.svg

Как сделать доску

Другой важной частью игры является доска. За рендеринг сетки и плиток отвечает компонент Board. Кажется, что Board дублирует логику компонента Tile, но есть небольшая разница. В Board хранится информация о его размере (ширине и высоте), а также о количестве столбцов и строк. Это противоположно плитке, которая знает только собственную позицию:  

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 }) => (
    
  ));

  return (
    
{tileList}
); };

Board использует BoardProvider для распределения ширины контейнера плитки и количества плиток в строке и столбце между всеми плитками и компонентом сетки:

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 (
    
      {children}
    
  );
};

Чтобы передать свойства всем дочерним компонентам, BoardProvider использует React Context API. Если какому-либо компоненту необходимо использовать некоторое доступное в провайдере значение, он может сделать это, вызвав хук  useBoard.

Эту тему я пропушу: более подробно я рассказал о ней в своём видео о Feature Toggles в React. Если вы хотите узнать о них больше, вы можете посмотреть его:

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

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

Компонент Game

Теперь можно задать правила игры и раскрыть интерфейс для игры. Я собираюсь начать с навигации: это поможет вам понять, почему логика игры реализована именно так:

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 ;
};

Как видите, логика игры будет обрабатываться хуком useGame, который представляет следующие свойства и методы:

  • tiles — это массив доступных на доске тайлов. Здесь используется TileMeta, речь о котором шла выше.

  • moveLeft перемещает все плитки на левую сторону доски.

  • moveRight сдвигает все плитки на правую сторону доски.

  • moveUp перемещает все плитки в верхнюю часть доски.

  • moveDown перемещает все плитки в нижнюю часть доски.

Мы работаем с колбеком throttledHandleKeyDown, чтобы предотвратить выполнение игроком множества движений одновременно.

Прежде чем игрок сможет вызвать другое движение, ему нужно дождаться завершения анимации. Этот механизм называется тормозящим (throttling) декоратором. Для него я решил использовать хук  useThrottledCallback пакета use-debounce .

Как работать с useGame                                         

Выше я упоминал, что компонент Game обрабатывает правила игры. Не хочется загромождать код, поэтому не будем записывать логиек непосредственно в компонент, а извлечём её в хук useGame. Этот хук основан на встроенном в React хуке useReducer. Начнём с определения формы состояния редюсера:

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

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

Состояние useReducer содержит следующие поля:

  • tiles — это хэш-таблица, отвечающая за хранение плиток. Она позволяет легко найти записи по их ключам, поэтому подходит идеально: находить плитки мы хотим по их идентификаторам.

  • byIds — это массив, содержащий все идентификаторы по возрастанию. Мы должны сохранить правильный порядок плиток, чтобы React не перерисовывал всю доску при каждом изменении состояния.

  • hasChange отслеживает изменения плиток. Если ничего не изменилось, новая плитка не создаётся.

  • inMotion указывает на то, движутся ли плитки. Если это так, то новая плитка не создаётся вплоть до завершения движения.

Экшены

useReducer требуется указать экшены, которые поддерживаются этим хуком:

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" };

За что отвечают эти экшены?

  • CREATE_TILE создаёт новую плитку и добавляет её в хэш-таблицу плиток. Флаг hasChange меняется на false : это действие всегда срабатывает при добавлении новой плитки на доску.

  • UPDATE_TILE обновляет существующую плитку; не изменяет её id, что важно для работы анимации. Воспользуемся этим экшеном, чтобы изменить положение плитки и её значение (во время слияния). Также UPDATE_TILE изменяет флаг hasChange на true.

  • MERGE_TILE объединяет исходную плитку и плитку назначения. После этой операции плитка назначения изменит своё значение, то есть к нему будет добавлено значение исходной плитки. Исходная плитка удаляется из таблицы плиток и массива byIds.

  • START_MOVE сообщает редюсеру, что он должен ожидать нескольких экшенов, а значит, должен дождаться завершения всех анимаций, прежде чем сможет сгенерировать новую плитку.

  • END_MOVE сообщает редюсеру, что все действия завершены, и он может создать новую плитку.

Логику этого редюсера вы можете написать самостоятельно или скопировать мою:

Редюсер

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;
  }
};

Если вы не понимаете, для чего мы определили эти экшены, не беспокойтесь — сейчас мы реализуем хук, который, я надеюсь, всё объяснит.

Как внедрить хук

Посмотрим на функцию, которая отвечает за ходы игрока. Сосредоточимся только на ходе влево: остальные ходы практически одинаковы.

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();

Видно, что к функции хода я решил привязать два колбека. Эта техника называется инверсией управления — таким образом потребитель функции сможет подставлять в выполняемую функцию собственные значения.

Если вы не знаете, как работает bind, вам стоит узнать об этом. Вопрос об этом часто задают на собеседованиях.

Колбек retrieveTileIdsByRow отвечает за поиск всех доступных в ряду непустых плиток (для перемещений влево или вправо). Если игрок делает движения вверх или вниз, будем искать все плитки в столбце.

Колбек calculateFirstFreeIndex, находит ближайшую к границе доски позицию на основе заданных параметров, таких как индекс плитки, индекс плитки в ряду или в столбце, количество объединённых плиток и максимально возможный индекс.

Посмотрим на логику функции перемещения. Её код я объяснил  в комментариях. Алгоритм может быть немного сложным, поэтому я решил, что построчные комментарии помогут его понять:

Код колбека RetrieveTileIdsByRowColumnCallback

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

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

  const move = (
    retrieveTileIdsByRowOrColumn: RetrieveTileIdsByRowOrColumnCallback,
    calculateFirstFreeIndex: CalculateTileIndex
  ) => {
    // new tiles cannot be created during motion.
    dispatch({ type: "START_MOVE" });

    const maxIndex = tileCount - 1;

    // iterates through every row or column (depends on move kind - vertical or horizontal).
    for (let tileIndex = 0; tileIndex < tileCount; tileIndex += 1) {
      // retrieves tiles in the row or column.
      const availableTileIds = retrieveTileIdsByRowOrColumn(tileIndex);

      // previousTile is used to determine if tile can be merged with the current tile.
      let previousTile: TileMeta | undefined;
      // mergeCount helps to fill gaps created by tile merges - two tiles become one.
      let mergedTilesCount = 0;

      // interate through available tiles.
      availableTileIds.forEach((tileId, nonEmptyTileIndex) => {
        const currentTile = tiles[tileId];

        // if previous tile has the same value as the current one they should be merged together.
        if (
          previousTile !== undefined &&
          previousTile.value === currentTile.value
        ) {
          const tile = {
            ...currentTile,
            position: previousTile.position,
            mergeWith: previousTile.id,
          } as TileMeta;

          // delays the merge by 250ms, so the sliding animation can be completed.
          throttledMergeTile(tile, previousTile);
          // previous tile must be cleared as a single tile can be merged only once per move.
          previousTile = undefined;
          // increment the merged counter to correct position for the consecutive tiles to get rid of gaps
          mergedTilesCount += 1;

          return updateTile(tile);
        }

        // else - previous and current tiles are different - move the tile to the first free space.
        const tile = {
          ...currentTile,
          position: indexToPosition(
            calculateFirstFreeIndex(
              tileIndex,
              nonEmptyTileIndex,
              mergedTilesCount,
              maxIndex
            )
          ),
        } as TileMeta;

        // previous tile becomes the current tile to check if the next tile can be merged with this one.
        previousTile = tile;

        // only if tile has changed its position will it be updated
        if (didTileMove(currentTile, tile)) {
          return updateTile(tile);
        }
      });
    }

    // wait until the end of all animations.
    setTimeout(() => dispatch({ type: "END_MOVE" }), animationDuration);
  };

Полный код useGame содержит более 400 строк.

Продолжить изучение современной веб-разработки вы сможете на наших курсах:

image-loader.svgПрофессии и курсы

Data Science и Machine Learning

Python, веб-разработка

Мобильная разработка

Java и C#

От основ — в глубину

А также:

Ссылки статьи

© Habrahabr.ru