Привет!

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

Год назад перед нами встала задача: сделать игру-квест с диалогами, 360-панорамой, drag-n-drop, звуками и мини-играми.

В этой статье расскажу про последнюю часть: как сделать мини-игру со звуками с помощью react, styled-components, mobx и howler.

Надеюсь, материал будет полезен начинающим реактивным разработчикам.

Что будет в статье:

  1. Правила

  2. Создаем игровое поле. 12 шагов

  3. Создаем вращение для блоков. 10 шагов

  4. Реализуем анимацию вращения. 10 шагов

  5. Создаем звуки. 3 шага

  6. Заключение

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 с кучей изображений, звуков и текста и не потеряться.

До встречи!

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


  1. Zoolander
    01.10.2021 07:24
    +1

    Если кто-то захочет расширить или написать подобную игру, и добавить еще блокирующих анимаций - советую использовать не одиночный флаг isAnimation (несколько паралелльно совпадающих анимаций будут его менять в непредсказуемом порядке), а массив или даже Map виде animationId -> animation flag

    при запуске анимация добавляет свой id в такой Map, после окончания убирает свой id

    проверка на идущие анимации - количество ключей в Map > 0

    позволяет работать со множеством анимаций


  1. kubk
    03.10.2021 12:06

    Отличные статьи, всегда интересно читать!