
Привет!
Меня зовут Сергей, я фронтенд-разработчик отдела спецпроектов KTS. Наш отдел занимается разработкой веб-приложений для промокампаний.
Помните, как в 1-й книге о Гарри Поттере Гермиона разгадывала логическую загадку с бутылочками волшебных зелий? Сегодня расскажу, как мы создавали именно такую игру.
Мы воспользуемся react-dnd, styled-components, mobx и createPortal.
Правила
У нас есть 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.
До встречи!
Другие статьи про frontend для начинающих:
Другие статьи про frontend для продвинутых:
