React Drag & Drop: «Игра в бутылки»
Привет!
Меня зовут Сергей, я фронтенд-разработчик отдела спецпроектов KTS. Наш отдел занимается разработкой веб-приложений для промокампаний.
Помните, как в 1-й книге о Гарри Поттере Гермиона разгадывала логическую загадку с бутылочками волшебных зелий? Сегодня расскажу, как мы создавали именно такую игру.
Мы воспользуемся react-dnd, styled-components, mobx и createPortal.
Правила
Разбираемся с пакетами
Создаем конфиг
Хранилище с логикой игры
Создаем игровое поле
Создаем бутылки и ячейки
Настраиваем Drag’n'Drop
Совместимость с тач-устройствами
Расставляем бутылки и играем
Правила
У нас есть 5 бутылок и 2 полки. При старте игры бутылки произвольно устанавливаются на одной полке. Их необходимо установить в правильном порядке на второй полке.
Разбираемся с пакетами
Реализуем Drag’n'Drop с помощью пакета react-dnd. Он создает контекст с провайдером, который отслеживает события drag
— и drop
-компонентов. Внутри компонентов доступны хуки useDrag
и useDrop
.
React-dnd не включает в себя браузерные и тач-события, но позволяет использовать дополнительные DnD-бэкенды.
Для работы с HTML Drag and Drop API подключим react-dnd-html5-backend. Браузерный API позволит нам использовать нативный механизм перетягивания, без дополнительных узлов и рендера в процессе перетаскивания. В билде у нас получатся HTML-элементы с атрибутом draggable.
Для совместимости с тач-устройствам выберем Touch events и подключим react-dnd-touch-backend. В этом случае придется создавать узел с перетаскиваемым объектом с помощью createPortal и стилизовать с помощью хука usePreview
.
Теперь пакет react-dnd-multi-backend сам выберет, какой DnD-бэкенд лучше использовать на устройстве.
Создаем конфиг
Перечислим наши бутылки, изображения бутылок, правильный порядок и полки:
enum BottlesEnum {
blue,
brown,
green,
red,
white,
}
const bottles: Record = {
[BottlesEnum.blue]: {
id: BottlesEnum.blue,
image: require('./img/blue.png'),
},
/*...*/
}
const correctPositions = [
BottlesEnum.green,
BottlesEnum.white,
BottlesEnum.brown,
BottlesEnum.red,
BottlesEnum.blue,
];
enum ShelvesEnum {
top,
bottom,
}
Текстом напишем подсказки для правильного порядка на основе изображений:
const rules = [
'По краям стоят круглые бутылки',
'Голубая бутылка стоит рядом с красной',
'В центре стоит бутылка без пробки',
'Красная бутылка стоит правее зеленой',
'Зеленая бутылка не стоит рядом с бутылками без пробки',
];
Хранилище с логикой игры
Для хранения состояний и логики будем использовать MobX. Он позволяет создавать локальное хранилище, или стор, и использовать его в нужном компоненте.
Само хранилище — обычный объект. Вся магия в том, что мы помечаем объекты, которые влияют на отображение компонента как наблюдаемые (observable
), и делаем наблюдаетелем сам компонент (observer
).
Таким образом на перерисовку компонента будет влиять только изменение observable
-полей. Подробнее можно прочитать здесь.
В сторе будем хранить содержимое ячеек, начальную позицию перетаскиваемой ячейки, логику стартового перемешивания, обработчики onDrag/onDrop
и проверку решения.
import { makeAutoObservable } from 'mobx';
import { createContext } from 'react';
type ShelfItemType = BottlesEnum | null;
type ShelfItemsListType = ShelfItemType[];
class BottlesGameStore {
draggedPosition: PositionType | null = null; // начальная позиция drag-элемента в момент перетаскивания
positions: Record; // содержимое ячеек на полках
constructor() {
makeAutoObservable(this);
this.shuffle();
}
shuffle(): void { // перемешиваем бутылки
this.positions = {
[ShelvesEnum.top]: new Array(correctPositions.length).fill(null),
[ShelvesEnum.bottom]: [...correctPositions].sort(
() => Math.random() - 0.5
),
};
this.isOneAtBottomCorrect && this.shuffle(); // перемешиваем еще раз, если хотя бы одна стоит на нужной позиции
}
get isOneAtBottomCorrect(): boolean {
return correctPositions.some(
(bottleId, columnIndex) =>
bottleId === this.positions[ShelvesEnum.bottom][columnIndex]
);
}
onDrag(position: PositionType): void {
this.draggedPosition = position;
}
onDrop(bottleId: number, position: PositionType): void {
const itemAtDrop = this.getItem(position); // проверяем бутылку в drop-ячейке
if (itemAtDrop || !this.draggedPosition || this.isCorrect) {
return;
}
this.setItem(this.draggedPosition, null); // удаляем бутылку из drag-ячейки, в которой она находилась в момент начала перетаскивания
this.setItem(position, bottleId); // сохраняем бутылку в drop-ячейку
}
getItem(position: PositionType): ShelfItemType {
const [shelfIndex, columnIndex] = position;
return this.positions[shelfIndex][columnIndex];
}
setItem(position: PositionType, item: ShelfItemType): void {
const [shelfIndex, columnIndex] = position;
this.positions[shelfIndex][columnIndex] = item;
}
get isCorrect(): boolean { // проверяем правильные позиции
return (
JSON.stringify(correctPositions) ===
JSON.stringify(this.positions[ShelvesEnum.top]) // элегантный способ