Привет!
Меня зовут Сергей, я фронтенд-разработчик отдела спецпроектов 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.
До встречи!
Комментарии (8)
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.Alexandroppolus
15.02.2022 02:09+2Плохая идея использовать в
getter
-е что-то что работает заO(n)
.Если геттер под мобиксовый компутед, то вполне.
faiwer
15.02.2022 02:23Согласен. Если есть мемоизация уже не так плохо. Но я бы лично, даже в этом случае, предпочёл явный вызов метода.
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-компонент. Не хотелось в примере плодить компоненты, которые не связаны с темой статьи. Вообще, конечно же, стоит выносить рендер списков в отдельные компоненты.
faiwer
15.02.2022 11:36Может быть есть какой-то совет по оптимизации?
Ага. Делать наиболее критичные по performance-у вещи в обход React-а. Это актуально как для анимаций, так и для таких вещей как d-n-d. Сердцем любого d-n-d являются event-handler-ы вроде
onMouseMove
. Вот там внутри можно вручную править координаты, не задействуя React вообще. Просто через доступ к DOMElement-у.Подумал, что саркатистичный комментарий указывает на это.
А, тогда пардон. Мой мозг сломан десятками проведённых интервью. Где народ на полном серьёзе указывает, что это true way. Вот я и не уловил сарказма :)
Alexandroppolus
15.02.2022 12:56+1Computed-getters в MobX мемоизируются.
но только пока за ними наблюдают, либо если они "keepAlive". Конкретно в вашем примере за isOneAtBottomCorrect не бывает наблюдений, и её действительно нет особого смысла делать геттером - всё равно при каждом вызове будет пересчет.
jamwid07
Почему эта комбинация неправильная. Она же логически верная
Ответ
wayvy Автор
Благодарю за внимательность! В правилах не учел этого варианта, уже исправил.