Привет!
Меня зовут Сергей, я фронтенд-разработчик отдела спецпроектов KTS. Наш отдел занимается разработкой веб-приложений для промокампаний.
Год назад перед нами встала задача: сделать игру-квест с диалогами, 360-панорамой, drag-n-drop, звуками и мини-играми.
В этой статье расскажу про последнюю часть: как сделать мини-игру со звуками с помощью react, styled-components, mobx и howler.
Надеюсь, материал будет полезен начинающим реактивным разработчикам.
Что будет в статье:
1 — Правила
Мы имеем 12 блоков, 3 из которых уникальные. Уникальные блоки необходимо установить в нужные позиции.
Игровое поле поделено на 3 круга по 6 блоков, которые вращаются по часовой стрелке.
Блоки нарисуем с помощью абсолютного позиционирования, для анимации вращения нарисуем оверлей. Основные блоки на время вращения скроем.
2 — Создаем игровое поле. 12 шагов
Шаг 1. Для начала сделаем конфиг игры. Перечислим виды блоков — один обычный и три уникальных:
export enum BlocksEnum {
regular = "regular",
red = "red",
blue = "blue",
green = "green"
}
Шаг 2. Перечислим изображения блоков:
export const blocksImages: Record<BlocksEnum, string> = {
[BlocksEnum.regular]: require("./img/block-regular.png"),
[BlocksEnum.red]: require("./img/block-red.png"),
[BlocksEnum.blue]: require("./img/block-blue.png"),
[BlocksEnum.green]: require("./img/block-green.png")
};
Блоки будем хранить в виде массива, где индекс — это позиция блока на игровом поле, а значение — вид блока в этой позиции, BlocksEnum:
Шаг 4. Перечислим индексы позиций, в которые нужно поместить уникальные блоки:
export enum CorrectEnum {
red = "1",
green = "7",
blue = "9"
}
Шаг 5. Также перечислим, какие виды блоков должны находиться в соответствующих индексах:
export const correctIndexes: Record<CorrectEnum, BlocksEnum> = {
[CorrectEnum.red]: BlocksEnum.red,
[CorrectEnum.green]: BlocksEnum.green,
[CorrectEnum.blue]: BlocksEnum.blue,
};
Далее при каждом вращении мы будем перебирать этот объект и по индексам CorrectEnum сравнивать с текущим массивом блоков. Для хранения состояний и логики будем использовать MobX. Он позволяет создавать локальное хранилище, или стор, и использовать его в нужном компоненте.
Само хранилище — обычный объект. Вся магия в том, что мы помечаем объекты, которые влияют на отображение компонента как наблюдаемые (observable), и делаем наблюдаемым сам компонент (observer).
Так различные сайд-эффекты не будут влиять на перерисовку компонента. Подробнее можно прочитать здесь.
Шаг 6. Создадим хранилище с методом перемешивания:
import { makeObservable, observable, action } from "mobx";
export class BoxStore {
// будем хранить массив из 12 видов блоков (9 обычных и 3 уникальные)
blocks: BlocksEnum[] = [];
constructor() {
makeObservable(this, {
blocks: observable,
isAnimation: observable,
rotationIndex: observable,
rotate: action.bound,
shuffle: action.bound,
setAnimation: action.bound,
});
this.shuffle();
}
shuffle(): void {
this.blocks = [
...new Array(9).fill(BlocksEnum.regular),
BlocksEnum.red,
BlocksEnum.blue,
BlocksEnum.green
].sort(() => Math.random() - 0.5);
// перемешиваем еще раз, если хотя бы один уникальный блок стоит на нужном месте
if (this.isOneCorrect) {
this.shuffle();
}
}
get isOneCorrect(): boolean {
return Object.keys(correctIndexes).some((index) =>
this.isCorrectIndex(index)
);
}
isCorrectIndex(index: number | string): boolean {
return correctIndexes[index] === this.blocks[index];
}
}
Шаг 7. У нас будет родительский компонент, компонент блока и компонент с оверлеем для вращения. Поэтому создадим контекст, чтобы иметь доступ к хранилищу в каждом из этих компонентов:
import { createContext } from 'react';
export const BoxGameContext = createContext<BoxStore | null>(null);
export 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;
};
Шаг 8. Для абсолютного позиционирования нам понадобятся позиции блоков, поэтому опишем массив с ними:
export type PositionType = [number, number];
export const blocksPositions: PositionType[] = [
[11.88, 22.93],
/*…*/
];
Шаг 9. Теперь создадим основной React-компонент с игрой. Подключим к нему хранилище, пробежимся по массиву с позициями и нарисуем блоки:
import * as React from 'react';
import { observer } from 'mobx-react';
const Box: React.FC = () => {
const [store] = React.useState(() => new BoxStore());
return (
/* Обернем игру в провайдер хранилища,
так он будет доступен в дочерних компонентах */
<BoxGameContext.Provider value={store}>
<BoxWrapper>
{blocksPositions.map((position, i) => (
<Block
type={store[i]} // находим вид блока в этой позиции
position={position}
index={i}
key={i}
/>
))}
</BoxWrapper>
</BoxGameContext.Provider>
);
};
// Теперь рендером управляет MobX
export default observer(Box);
Шаг 10. Для отображения в браузере стилизуем BoxWrapper с помощью styled-components.
import styled from 'styled-components';
export const BoxWrapper = styled.div`
background: url(${require(./img/box.jpg).default}) no-repeat center / contain;
width: 69.7rem;
height: 49rem;
position: absolute;
margin: auto;
top: 0;
right: 0;
bottom: 0;
left: 0;
`;
Компонент Block выглядит так:
import { observer } from 'mobx-react';
export const generatePositionStyle = (position: PositionType) => ({
top: position[0] + "rem",
left: position[1] + "rem"
});
const Block: React.FC<Props> = ({ position, type, index }: BlockProps) => {
const store = useStore(BoxGameContext);
return (
<BlockImg
/*
Абсолютные позиции добавим в inline style,
чтобы избежать генерации множества css-классов
(количество позиций умножить на количество видов блоков)
*/
style={generatePositionStyle(position)}
type={type}
/>
);
};
export default observer(Block);
Шаг 11. Для блока и позиционирования оверлея с вращением нам понадобится радиус блока. Добавим его в конфиг:
export const BLOCK_RADIUS = 3.6;
Шаг 12. Стилизуем блок. Из конфига возьмем картинку по виду блока и радиус:
export const generateCicleStyle = (radius: number) => ({
"border-radius": "50%",
width: radius * 2 + "rem",
height: radius * 2 + "rem"
});
export const BlockImg = styled.div<{
type: BlocksEnum;
}>`
position: absolute;
${generateCicleStyle(BLOCK_RADIUS)};
background: url(${(props) => blocksImages[props.type]}) no-repeat center / contain;
`;
У нас получилась заготовка внешнего вида. Теперь давайте вращать!
3 — Создаем вращение блоков. 10 шагов
Мы имеем три центральных блока. При нажатии на них соседние 6 вращаются по часовой стрелке.
Блоки мы храним в массиве, поэтому опишем последовательность индексов, в которых происходит вращение. При нажатии берем из хранилища вид блока по каждому индексу последовательности и меняем его с видом блока со следующим индексом.
Шаг 1. Перечислим индексы центральных блоков:
export enum RotationsEnum {
topLeft = "4",
topRight = "5",
bottom = "8"
}
Шаг 2. Добавим метод в хранилище:
// 3 круга по 6 блоков
export const rotations: Record<RotationsEnum, number[]> = {
[RotationsEnum.topLeft]: [0, 1, 5, 8, 7, 3],
[RotationsEnum.topRight]: [1, 2, 6, 9, 8, 4],
[RotationsEnum.bottom]: [4, 5, 9, 11, 10, 7]
};
Шаг 3. Добавим к индексам массив индексов вращения:
rotate(clickIndex: number): void {
if (!rotations[clickIndex]) {
return;
}
const blocks = [...this.blocks]; // создаем новый массов видов блоков
const rotationsMap = rotations[clickIndex]; // берем массив индексов вращений
// и перебираем его
rotationsMap.forEach((rotationIndex: number, index: number) => {
if (rotationsMap.length - 1 === index) {
// меняем виды блоков по нулевому и последнему индексу вращения
this.blocks[rotationsMap[0]] = blocks[rotationsMap[rotationsMap.length - 1]];
} else {
// меняем виды блоков по соседним индексам вращения
this.blocks[rotationsMap[index + 1]] = blocks[rotationIndex];
}
});
}
Шаг 4. Проверяем, является ли этот блок вращающим, и вешаем store.rotate к элементу блока:
const isRotation = rotations[index];
/*…*/
<BlockImg
/*…*/
onClick={isRotation ? () => store.rotate(index) : undefined}
/>
Шаг 5. Осталось добавить один геттер с проверкой правильных индексов, и основная логика готова:
get isCorrect(): boolean {
return Object.keys(correctIndexes).every((block) =>
this.isCorrectIndex(block)
);
}
4 — Реализуем анимацию вращения. 10 шагов
Шаг 1. Напомню, пользователь нажимает на центральные блоки и тем самым вращает соседние. В хранилище добавим метод, меняющий индекс центрального блока:
isAnimation = false;
rotationIndex: number | null = null;
setAnimation(value: boolean, index?: number): void {
this.isAnimation = value;
this.rotationIndex = index || null;
}
Шаг 2. Добавим в конфиг длительность анимации:
export const ANIMATION_TIME = 500;
Шаг 3. Для обработки клика создадим функцию в компоненте <Block />:
const handleRotation = () => {
if (store.isAnimation) {
return;
}
store.setAnimation(true, index);
setTimeout(() => {
store.rotate(index);
store.setAnimation(false);
});
}
Шаг 4. Порядок блоков в хранилище изменяется только после анимации. На это время мы скроем блоки, которые участвуют в анимации, и нарисуем поверх новые. В Block.tsx добавим проверку:
const isHide = () => {
if (!store.rotationIndex) {
return false;
}
const rotationIndexes = rotations[store.rotationIndex];
return Object.keys(rotationIndexes)
.some((key) => rotationIndexes[key] === index);
};
Шаг 5. Обновим стиль блока:
export const BlockImg = styled.div<{
/*…*/
isHide?: boolean;
}>`
/*…*/
display: ${(props) => (props.isHide ? 'none' : 'block')};
`;
Шаг 6. Положим внутрь компонент-оверлей для ховера и анимации:
const withRotaionCircle =
isRotation && (!store.rotationIndex || store.rotationIndex === index);
<BlockImg
/*…*/
onClick={isRotation && !store.isCorrect ? handleRotation : undefined}
isHide={isHide()}
withRotaionCircle={withRotaionCircle}
>
{withRotaionCircle && (
<RotationCircle position={position} type={type} overlayIndex={index} />
)}
</BlockImg>
Шаг 7. Добавим в конфиг угол поворота, радиус оверлея и смещение оверлея относительно центрального блока:
export const ROTATION_ANGLE = 360 / 6; // 6 блоков в круге
export const ROTATION_CIRCLE_RADIUS = 12.7; // радиус оверлея с анимацией
Шаг 8. Оверлей является дочерним элементом обычного блока, поэтому по умолчанию он позиционируется по верхнему левому краю родительского элемента. Чтобы выровнять оверлей по центру с родительским блоком, нужно найти разность половин их ширин и сдвинуть оверлей вверх и влево на эту разность. У нас как раз есть радиусы:
export const ROTATION_CIRCLE_SHIFT = ROTATION_CIRCLE_RADIUS - BLOCK_RADIUS;
Шаг 9. Создадим компонент RotationCircle:
const RotationCircle: React.FC<Props> = ({ position, type, index }: BlockProps) => {
const store = React.useContext(BoxGameContext);
const getPosition = (inCirclePosition: PositionType): PositionType => {
return [
inCirclePosition[0] - position[0] + ROTATION_CIRCLE_SHIFT,
inCirclePosition[1] - position[1] + ROTATION_CIRCLE_SHIFT,
];
};
const rotate = store.isAnimation && store.rotationIndex === index;
return (
/*
Рисуем оверлей для вращающихся блоков
Так как основные блоки на время анимации будут скрыты,
будем рисовать временные блоки для анимации внутри оверлея
*/
<RotationCircleWrapper
$rotate={store.isAnimation && store.rotationIndex === index}
isCorrect={store.isCorrect}
>
{Object.keys(rotations[index]).map((rotationBlockIndex, i) => {
// Проходимся по индексам вращений - рисуем блоки, которые будут вращаться
const block = rotations[index][rotationBlockIndex];
const blockPosition = getPosition(blocksPositions[block]);
return (
<BlockImg
position={blockPosition}
type={store.blocks[block]}
key={i}
$rotate={isRotate}
/>
);
})}
{/* Рисуем центральный блок */}
<BlockImg
position={getPosition(position)}
type={type}
isRotation
$rotate={rotate}
/>
</RotationCircleWrapper>
);
};
export default observer(RotationCircle);
Шаг 10. Стилизуем оверлей RotationCircleWrapper:
import styled, { css } from 'styled-components';
export const RotationCircleWrapper = styled.div<{
$rotate: boolean;
isCorrect: boolean;
}>`
position: absolute;
z-index: 1;
opacity: 0;
transform: rotate(0);
will-change: transform, opacity;
background: url(${require('./img/rotation-circle.svg').default}) no-repeat
center / contain;
${generateCicleStyle(ROTATION_CIRCLE_RADIUS)}
left: -${ROTATION_CIRCLE_SHIFT}rem;
top: -${ROTATION_CIRCLE_SHIFT}rem;
/*
Отключаем события для оверлея, фактически мы будем кликать на основной блок
*/
pointer-events: none;
/* Переход для ховера */
transition: opacity 0.2s linear;
/* Активируем mixin в момент вращения */
${(props) => props.$rotate && rotationCircleMixin};
/* Скрываем оверлей, когда головоломка решена */
display: ${(props) => (props.isCorrect ? 'none' : 'block')};
`;
const rotationCircleMixin = css`
transition: transform ${ANIMATION_TIME}ms linear;
transform: rotate(${ROTATION_ANGLE}deg);
opacity: 1;
`;
Внешний вид готов:
5 — Создаем звуки. 3 шага
Но какая игра без звуков? Они должны быть даже в мини-игре. Поэтому создадим аудиоконтроллер.
Для работы с аудио мы будем использовать Howler. Эта библиотека поддерживает аудиоконтексты для большинства браузеров, имеет большие возможности для управления воспроизведением и делает за нас множество низкоуровневой работы.
Шаг 1. Перечислим наши звуки:
export enum AudioEnum {
boxRotate,
boxOpened,
}
export const AUDIOS: Record<AudioEnum, HowlOptions> = {
[AudioEnum.boxRotate]: {
src: require('./sounds/box-rotate.mp3').default,
},
[AudioEnum.boxOpened]: {
src: require('./sounds/box-opened.mp3').default,
},
}
Шаг 2. Реализуем предзагрузку файлов и воспроизведение:
export class AudioController {
audios: Partial<Record<AudioEnum, Howl>> = {};
// в момент загрузки будем передавать массив ключей нужных звуков
async preload(audios: AudioEnum[]): Promise<unknown> {
const loadAudio = (key: AudioEnum) =>
new Promise<void>((res) => {
this.addAudio(key);
const onLoad = () => res();
this.audios[key]?.on("load", onLoad);
this.audios[key]?.on("loaderror", onLoad);
this.audios[key]?.load();
});
return Promise.all(audios.map(loadAudio));
}
addAudio(key: AudioEnum, audio?: HowlOptions): void {
if (!(key in this.audios)) {
this.audios[key] = new Howl(audio || AUDIOS[key]);
}
}
play = (key: AudioEnum): Howl | null => {
if (!(key in this.audios)) {
this.addAudio(key);
}
const audio = this.audios[key];
if (audio) {
audio.play();
return audio;
}
return null;
};
}
Шаг 3. Добавим контроллер в наше хранилище:
export class BoxStore {
/*...*/
audioController = new AudioController();
/*...*/
}
Теперь мы можем создать эффекты для аудио в компоненте <Box />.
Первый эффект выполнит предзагрузку звуков во время монтирования компонента, второй воспроизведет звук открытия шкатулки при решении:
React.useEffect(() => {
store.audioController.preload([
AudioEnum.boxOpened,
AudioEnum.boxRotate,
]);
}, []);
React.useEffect(() => {
if (store.isCorrect) {
store.audioController.play(AudioEnum.boxOpened);
}
}, [store.isCorrect]);
По клику в компоненте <Block /> будем воспроизводить звук вращения:
const handleRotation = () => {
/*...*/
store.audioController.play(AudioEnum.boxRotate);
/*...*/
}
6 — Заключение
Добавим сообщение в <Box /> о решенной головоломке:
<BoxWrapper>
/*...*/
{store.isCorrect && <Success>Try not. Do, or do not. There is no try.</Success>}
/*...*/
</BoxWrapper>
export const Success = styled.div`
position: absolute;
width: 100%;
text-align: center;
top: 1rem;
font-size: 3rem;
background: green;
color: white;
`;
Шкатулка готова:
Готовый код лежит здесь.
Поиграть можно здесь.
Надеюсь, статья была интересной и полезной. Буду рад комментариям и советам по оптимизации.
В следующий раз расскажу, как сделать панораму на Three.js с кучей изображений, звуков и текста и не потеряться.
До встречи!
Zoolander
Если кто-то захочет расширить или написать подобную игру, и добавить еще блокирующих анимаций - советую использовать не одиночный флаг isAnimation (несколько паралелльно совпадающих анимаций будут его менять в непредсказуемом порядке), а массив или даже Map виде animationId -> animation flag
при запуске анимация добавляет свой id в такой Map, после окончания убирает свой id
проверка на идущие анимации - количество ключей в Map > 0
позволяет работать со множеством анимаций