Как сделать игру 2048 на React

Kate

Administrator
Команда форума

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

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

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

4fb9b44f656ca8f9729fa7870bb567a0.gif

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

  • Число на новой плитке всегда 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;
  • плитка изменяет значение — предыдущее значение будет отличаться от текущего.
И вот результат:

ec160c3f66fe2a4be87858035da577c6.gif

Вы могли заметить, что я работаю с пользовательским хуком 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 позволяет указать, какая плитка находится наверху.
d50aac9c55aad87e7595b8b8e402739b.gif

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

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

 
Сверху