[Перевод] Как сделать игру 2048 на React
Автор этого туториала сосредоточился на анимации. Он использовал хуки библиотеки React, её Context API, а также TypeScript и LESS. В конце вы найдёте ссылки на игру, её код и демо анимаций. Подробности рассказываем под катом, пока у нас начинается курс по Frontend-разработке.
Правила игры 2048
Числа на плитках — только степени двойки, начиная с самой 2. Игрок объединяет плитки с одинаковыми числами. Числа суммируются, пока дело не дойдёт до 2048. Игрок должен добраться до плитки с числом 2048 за наименьшее количество шагов.
Если доска заполнена и нет возможности сделать ход, например объединить плитки вместе, — игра окончена.
Для целей статьи я сосредоточился на игровой механике и анимации и пренебрёг деталями:
Число на новой плитке всегда 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
;плитка изменяет значение — предыдущее значение будет отличаться от текущего.
И вот результат:
Вы могли заметить, что я работаю с пользовательским хуком 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
позволяет указать, какая плитка находится наверху.
Как сделать доску
Другой важной частью игры является доска. За рендеринг сетки и плиток отвечает компонент 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 строк.
Продолжить изучение современной веб-разработки вы сможете на наших курсах:
Профессии и курсы
Data Science и Machine Learning
Python, веб-разработка
Мобильная разработка
Java и C#
От основ — в глубину
А также:
Ссылки статьи