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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Без анимированного движения плиток по доске игра будет смотреться неаккуратно. Такую анимацию легко создать при помощи 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 позволяет указать, какая плитка находится наверху.

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

Другой важной частью игры является доска. За рендеринг сетки и плиток отвечает компонент 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 }) => (
    <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>
  );
};

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 (
    <BoardContext.Provider value={{ containerWidth, tileCountPerRow }}>
      {children}
    </BoardContext.Provider>
  );
};

Чтобы передать свойства всем дочерним компонентам, 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 <Board tiles={tiles} tileCountPerRow={4} />;
};

Как видите, логика игры будет обрабатываться хуком 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 строк.

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

Профессии и курсы
Ссылки статьи

Комментарии (4)


  1. eimrine
    12.11.2021 03:06
    +4

    Подробности рассказываем под катом, пока у нас начинается курс по Frontend-разработке.
    А сколько стоит? Нигде не нашёл на вашем веб-сайте цену на курс, нашёл только что у вас скидка 50%. Без понимания цены никакие данные никуда отправлять не хочу.


  1. navferty
    12.11.2021 10:20

    В своё время, от нечего делать, сделал 2048 на vba в excel'е. Даже сам иногда залипаю)


  1. khv2online
    14.11.2021 21:27

    Демка на гитхабе не работает. Не кликается и не тянется. Ничего не понял....


    1. KD637 Автор
      14.11.2021 21:41

      Добрый день. Автор не делал управление мышью, поэтому сначала может показаться, что демка не работает. Но она управляется стрелками. Уточним этот момент прямо в тексте, спасибо.