Привет!

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

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

Мы воспользуемся react-dnd, styled-componentsmobx и 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, BottleType> = {
	[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<ShelvesEnum, ShelfItemsListType>; // содержимое ячеек на полках

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

  get isUncorrect(): boolean { // проверяем, что верхняя полка заполнена, но позиции не верны
		return (
			!this.isCorrect &&
			this.positions[ShelvesEnum.top].every((position) => position !== null)
		);
	}
}

Для быстрого доступа к хранилищу из любого дочернего компонента создадим его контекст.

const BottlesGameContext = createContext<BottlesGameStore | null>(null); // создаем контекст с нашим стором

const useStore = <T>(context: React.Context<T | null>): T => { // быстрый доступ к контексту
	const data = useContext(context);
	if (!data) {
		throw new Error('Using store outside of context');
	}
	return data;
};

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

Для стилизации элементов будем использовать styled-components. Это CSS-in-JS библиотека, которая позволяет стилизовать компоненты опционально с помощью прокидывания пропсов, а так же наследовать стили других компонентов.

Стилизуем контейнер и игровое поле с полками и правилами
const Container = styled.div`
	position: absolute;
	top: 0;
	left: 0;
	right: 0;
	bottom: 0;
	margin: auto;
	width: 120rem;
	height: 50rem;
	text-align: center;
	background: black;
`;

const Playground = styled.div`
	position: absolute;
	width: 100%;
	height: 100%;
`;

const Title = styled.div`
	font-size: 2.5rem;
	color: white;
	text-align: center;
	z-index: 1;
	font-family: monospace;
`;

const Shelves = styled.div`
	width: 76.8rem;
	height: 29.83rem;
	position: absolute;
	background: url(${require(./img/shelves.png)}) no-repeat center / contain;
	top: 0;
	bottom: 0;
	margin: auto;
`;

const Rules = styled.ul`
	color: white;
	text-align: left;
`;

Теперь создадим основной компонент с игрой.

Обернём все в DnD-провайдер, определим для него «бэкенд» и опции. Также инициируем наше MobX-хранилище и добавим обёртку с его провайдером.

Нарисуем игровое поле, полки и правила из конфига.

import { DndProvider } from 'react-dnd';
import MultiBackend from 'react-dnd-multi-backend';
import HTML5toTouch from 'react-dnd-multi-backend/dist/esm/HTML5toTouch';

const BottlesGame: React.FC = () => {
	const [store] = React.useState(() => new BottlesGameStore());
	return (
		<DndProvider backend={MultiBackend} options={HTML5toTouch}>
			<BottlesGameContext.Provider value={store}>
				<Container>
					<Playground>
						<Title>Расставь бутылки на верхней полке в нужном порядке</Title>
						<Shelves />
						<Rules>
							{rules.map((rule, i) => (
								<li key={i}>{rule}</li>
							))}
						</Rules>
					</Playground>
				</Container>
			</BottlesGameContext.Provider>
		</DndProvider>
	);
};

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

Стилизуем ячейку с абсолютным позиционированием внутри полки. Этот стиль будет общим для drag- и drop-компонентов:

type PositionType = [ShelvesEnum, BottlesEnum];

const getShelfItemPositionStyle = ([top, left]: PositionType) => `
	top: ${top * DROP_HEIGHT}rem;
	left: ${left * DROP_WIDTH + SHELF_START_H}rem;
`;

const DNDItem = styled.div<{
	position: PositionType;
}>`
	${({ position }) => getShelfItemPositionStyle(position)}
	position: absolute;
`;

const SHELF_START_H = 2.7; // горизонтальный отступ между началом полки и первой ячейкой
const DROP_WIDTH = 14.8; // ширина ячейки на полке
const DROP_HEIGHT = 15; // высота ячейки на полке
const DRAG_SIZE = 12.1; // ширина/высота draggable бутылки

Стилизуем бутылку, которую будем перетаскивать (drag):

const BottleDragWrapper = styled(DNDItem)<{
	isDragging: boolean;
}>`
	z-index: 1;
	width: ${DRAG_SIZE}rem;
	height: ${DRAG_SIZE}rem;
	background-repeat: no-repeat;
	background-position: center;
	background-size: contain;

	/* В момент перетаскиывания скрываем элемент */
	visibility: ${(props) => (props.isDragging ? 'hidden' : 'visible')};
`;

Стилизуем ячейку, в которую будем бросать (drop) и изображение для перетаскивания на тач-устройствах:

const BottleDropWrapper = styled(DNDItem)`
	width: ${DROP_WIDTH}rem;
	height: ${DROP_HEIGHT}rem;
`;

const BottlePreviewImg = styled.img`
	width: ${DRAG_SIZE}rem;
	height: ${DRAG_SIZE}rem;
`;

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

Сейчас мы создадим компонент BottleDrag — бутылку, которую будем перетягивать.

Воспользуемся хуком useDrag из пакета react-dnd. В нем определяем тип перетаскиваемого элемента (у нас используется только BOTTLE_DND_TYPE) и конфиг бутылки, который будет храниться в контексте react-dnd и доставаться в drop-компоненте. А также привязываем обработчик onDrag из нашего хранилища к началу перетягивания. Из хука получаем состояние isDragging и ref для DOM-элемента.

import { useDrag } from 'react-dnd';

type BottleDragProps = {
	bottle: BottleType;
	position: PositionType;
};

const BOTTLE_DND_TYPE = 'BOTTLE_DND_TYPE';

const BottleDrag: React.FC<BottleDragProps> = ({
	bottle,
	position,
}: BottleDragProps) => {

	const store = useStore(BottlesGameContext);

	const [
		{ isDragging },
		drag // ref drag-элемента
	] = useDrag({
		item: {
			type: BOTTLE_DND_TYPE, // с помощью типа можно использовать несколько совместимых drag и drop элементов в одном контексте
			bottle // передаем конфиг текущей бутылки в контекст
		},
		collect: (monitor) => ({
			isDragging: monitor.isDragging(), // фиксируем события в момент рендера
		}),
		begin: () => store.onDrag(position),
	});

	return (
		<BottleDragWrapper
			ref={drag}
			position={position}
			isDragging={isDragging}
			style={{
				backgroundImage: `url(${bottle.image})`,
			}}
		/>
	);
};

Создадим компонент BottleDrop — ячейка, в которую будем перетягивать бутылку.

Здесь воспользуемся хуком useDrop. В нем определяем тип перетаскиваемого элемента и привязываем обработчик onDrop из хранилища. Из хука также получаем ref для DOM-элемента.

type BottleDropProps = {
	position: PositionType;
};

const BottleDrop: React.FC<BottleDropProps> = ({
	position,
}: BottleDropProps) => {
const store = useStore(BottlesGameContext);
	const [,
		drop // ref drop-элемента
	] = useDrop({
		accept: BOTTLE_DND_TYPE, // в этот drop-элемент можно перетащить только drag-элемент с данным типом*
		drop: (item) => {
			store.onDrop(item.bottle.id, position);
		},
	});

	return <BottleDropWrapper position={position} ref={drop} />;
};

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

В отличие от десктопных, в мобильных браузерах перетягивание реализуется не нативным механизмом перетягивания, а с помощью обработки жестов. Поэтому отрисовку элемента в момент перетаскивания придется сделать самим.

Реализуем DndPreview с помощью createPortal.

Порталы позволяют рендерить дочерние элементы в DOM-узел, который находится вне DOM-иерархии родительского компонента.

Создадим узел рядом с узлом нашего приложения — в нём будем рисовать изображение drag-элемента и передавать позицию через inline-стиль.

type DndPreviewPortalProps = { children: React.ReactNode; display: boolean };

const createDndElement = () => {
	const el = document.createElement('div');
	el.className = 'dnd-item';
	return el;
};

const DndPreviewPortal: React.FC<DndPreviewPortalProps> = ({
	children,
	display,
}: DndPreviewPortalProps) => {
	const el = useRef(createDndElement()).current;

	useEffect(() => {
		display ? document.body.appendChild(el) : document.body.removeChild(el);
	}, [display, el]);

	useEffect(() => {
		return () => {
			document.body.removeChild(el);
	};
	}, [el]);

	if (!display) {
		return null;
	}

return createPortal(children, el);
};

Теперь нужно создать компонент BottlePreview с изображением drag-элемента.

Воспользуемся хуком usePreview. Из него получим состояние display, конфиг бутылки item из контекста react-dnd, style с позиционированием и ref DOM-элемента.

import { usePreview } from 'react-dnd-multi-backend';

const BottlePreview: React.FC = () => {
	const { display, item, style, ref } = usePreview();
	if (!display || item.type !== BOTTLE_DND_TYPE) {
		return null;
	}

	return (
		<DndPreview display={display}>
			<BottlePreviewImg src={item.bottle.image} style={style} ref={ref} />
		</DndPreview>
	);
};

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

Подключим только что созданные Drag-, Drop- и Preview-компоненты в игру.

const BottlesGame: React.FC = () => {
/* ... */
<Shelves>
	<BottlePreview />
  
	{store.positionKeys.map((shelfKey, shelfIndex) => // пробегаемся по позициям
    store.getShelf(shelfKey).map((bottleKey, bottleIndex) => { // находим бутылки на полках
      if (bottleKey === null) {
        return null;
      }
      return (
      	<BottleDrag
        	bottle={bottles[bottleKey]}
        	position={[shelfIndex, bottleIndex]}
          key={`bottle-${shelfKey}-${bottleKey}`}
				/>
  		);
     })
  )}
  
  {Object.keys(bottles).map((columnIndex) => ( // рендерим по ячейке для бутылки на каждой полке
  	<div key={`bottlePlaceholder-${columnIndex}`}>
  		<BottleDrop position={[ShelvesEnum.top, Number(columnIndex)]} />
  		<BottleDrop
  			position={[ShelvesEnum.bottom, Number(columnIndex)]}
  		/>
  	</div>
  ))}
</Shelves>
/* ... */
};

Остается добавить сообщения о верном и неверном решениях:

const BottlesGame: React.FC = () => {
/* ... */
<Playground>
	{store.isUncorrect ? (
		<Title>Эта расстановка неверная, попробуй еще раз</Title>
	) : (
		<Title>Расставь бутылки на верхней полке в нужном порядке</Title>
	)}
</Playground>
{store.isCorrect && <div>Success!</div>}
/* ... */
};

Поздравляю, игра готова!

Готовый код лежит здесь.

Поиграть можно здесь.

В моей прошлой статье Создание мини-игры «Шкатулка» можно узнать подробнее о MobX-сторах и создании аудиоконтроллера с помощью библиотеки Howler.

До встречи!

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


  1. jamwid07
    14.02.2022 14:39
    +1

    Почему эта комбинация неправильная. Она же логически верная

    Ответ


    1. wayvy Автор
      14.02.2022 14:57
      +1

      Благодарю за внимательность! В правилах не учел этого варианта, уже исправил.


  1. faiwer
    14.02.2022 17:39
    +1

    Немного критики.


    get isOneAtBottomCorrect(): boolean {
            return correctPositions.some(

    Плохая идея использовать в getter-е что-то что работает за O(n).


    return (
      JSON.stringify(correctPositions) ===
      JSON.stringify(this.positions[ShelvesEnum.top]) // элегантный способ ????
    );

    Один из вопросов на собеседованиях почему это не "элегантный способ" а треш. Come on.


    <Rules>
      {rules.map((rule, i) => (
        <li key={i}>{rule}</li>
      ))}
    </Rules>

    Лучше так:


    <Rules rules={rules}/>

    Вы уже выделили под это компонент. И даже назвали его rules. Но зачем-то передаёте туда children prop. Не очень логично, имхо. Да и не должно быть никаких <li/> на таком высоком уровне иерархии.


    Ну и самое главное, у вас для drag-n-drop используется React-way. Т.е. rerender на каждый onMouseMove. Мягко говоря не производительный вариант. Для такой простой задачи может быть ещё и сойдёт, но я не рекомендую такое тащить в более сложные кейсы. Там 99% времени CPU будет заниматься чем угодно, но не D-n-D.


    1. Alexandroppolus
      15.02.2022 02:09
      +2

      Плохая идея использовать в getter-е что-то что работает за O(n).

      Если геттер под мобиксовый компутед, то вполне.


      1. faiwer
        15.02.2022 02:23

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


    1. wayvy Автор
      15.02.2022 11:30
      +1

      Ну и самое главное, у вас для drag-n-drop используется React-way.

      Естественно, ведь статья называется React Drag & Drop. Может быть есть какой-то совет по оптимизации?

      Плохая идея использовать в getter-е что-то что работает за O(n).

      Computed-getters в MobX мемоизируются.

      Один из вопросов на собеседованиях почему это не "элегантный способ" а треш. Come on.

      Конечно же, треш. Подумал, что саркатистичный комментарий указывает на это. Впредь буду аккуратней!

      Вы уже выделили под это компонент. И даже назвали его rules. Но зачем-то передаёте туда children prop. Не очень логично, имхо. ...

      Здесь Rules не функциональный, а styled-компонент. Не хотелось в примере плодить компоненты, которые не связаны с темой статьи. Вообще, конечно же, стоит выносить рендер списков в отдельные компоненты.


      1. faiwer
        15.02.2022 11:36

        Может быть есть какой-то совет по оптимизации?

        Ага. Делать наиболее критичные по performance-у вещи в обход React-а. Это актуально как для анимаций, так и для таких вещей как d-n-d. Сердцем любого d-n-d являются event-handler-ы вроде onMouseMove. Вот там внутри можно вручную править координаты, не задействуя React вообще. Просто через доступ к DOMElement-у.


        Подумал, что саркатистичный комментарий указывает на это.

        А, тогда пардон. Мой мозг сломан десятками проведённых интервью. Где народ на полном серьёзе указывает, что это true way. Вот я и не уловил сарказма :)


      1. Alexandroppolus
        15.02.2022 12:56
        +1

        Computed-getters в MobX мемоизируются.

        но только пока за ними наблюдают, либо если они "keepAlive". Конкретно в вашем примере за isOneAtBottomCorrect не бывает наблюдений, и её действительно нет особого смысла делать геттером - всё равно при каждом вызове будет пересчет.