React Drag & Drop: «Игра в бутылки»

image-loader.svg

Привет!

Меня зовут Сергей, я фронтенд-разработчик отдела спецпроектов KTS. Наш отдел занимается разработкой веб-приложений для промокампаний. 

Помните, как в 1-й книге о Гарри Поттере Гермиона разгадывала логическую загадку с бутылочками волшебных зелий? Сегодня расскажу, как мы создавали именно такую игру.

Мы воспользуемся react-dnd, styled-components,  mobx и createPortal.

  1. Правила

  2. Разбираемся с пакетами

  3. Создаем конфиг

  4. Хранилище с логикой игры

  5. Создаем игровое поле

  6. Создаем бутылки и ячейки

  7. Настраиваем Drag’n'Drop

  8. Совместимость с тач-устройствами

  9. Расставляем бутылки и играем

Правила

У нас есть 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]) // элегантный способ 
    
            

© Habrahabr.ru