2 марта 2018

Разрабатываем игру на SVG + React. Часть 1

CSSJavaScriptHTMLReactJS
Перевод
Tutorial
Автор оригинала: Bruno Krebs

TL;DR: в этих сериях вы узнаете, как заставить React и Redux управлять SVG элементами для создания игры. Полученные в этой серии знания позволят вам создавать анимацию не только для игр. Вы можете найти окончательный вариант исходного кода, разработанного в этой части, на GitHub.


image


Название игры: "Пришельцы, проваливайте домой!"


Игра, разработкой которой вы займетесь в этой серии, называется "Пришельцы, проваливайте домой!". Идея игры проста: у вас будет пушка, с помощью которой вы будете сбивать "летающие диски", которые пытаются вторгнуться на Землю. Для уничтожения этих НЛО вам нужно произвести выстрел из пушки, наведя курсор и кликнув мышью.


Если вам интересно, можете найти и запустить итоговую версию игры здесь (ссылка умерла — прим.переводчика). Но не увлекайтесь игрой, у вас есть работа!


Предварительные условия


Для успешного ознакомления со статьей вам необходимо иметь определенный уровень знаний о вэб-разработке (главным образом о JavaScript) и компьютер с предустановленными Node.js и NPM. Вам не пригодятся глубокие знания JavaScript, React, Redux и SVG для успешного прохождения этой серии туториалов. Впрочем, если вы в теме, вам будет проще понять некоторые части и их соответствие друг другу.


Тем не менее, в серии содержатся ссылки на соответствующие статьи, посты и документы, которые помогут лучше объяснить темы, заслуживающие повышенного внимания.


Прежде чем начать


Хотя в предыдущем разделе не было упоминаний о Git, стоит отметить, что это хороший инструмент для решения некоторых проблем. Все профессиональные разработчики используют Git (либо иную систему управления версиями, например, Mercurial или SVN) в процессе своей деятельности, даже в ходе "домашних" проектов.


Зачем создавать проект без резервного копирования? Вам даже не нужно за это платить. Вы можете воспользоваться такими сервисами, как GitHub (лучший!) или BitBucket (неплох, если быть честным) и сохранить ваш код в надежных облачных инфраструктурах.


Использование подобных инструментов даст вам уверенность в сохранности вашего кода и облегчит непосредственно процесс разработки. Например, когда вы создадите новую версию приложения с "багами", вы легко сможете вернуться к предыдущей версии кода, совершив всего несколько простых действий используя Git.


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


В общем, сделайте себе одолжение и установите Git. Также создайте учетную запись на GitHub (если у вас ее еще нет) и репозиторий для сохранения вашего проекта. Затем, после завершения каждой секции, коммитьте изменения в репозиторий. Ох, и не забывайте пушить ваши изменения.


Быстрый старт проекта с помощью Create-React-App


Самый первый ваш шаг для создания игры с React, Redux и SVG — использование create-react-app для быстрого старта вашего проекта. Как вы, вероятно, уже знаете (ничего страшного, если это не так), create-react-app — это инструмент с открытым исходным кодом, поддерживаемый Facebook, который помогает разработчикам мгновенно начать работу с React. Имея установленные Node.js and NPM (версия последнего должна быть 5.2 и выше), вы можете использовать create-react-app, даже не устанавливая его:


# npx скачает (если нужно)
# create-react-app и запустит
npx create-react-app aliens-go-home

# перейдите в директорию с проектом
cd aliens-go-home

Эта "тулза" создаст стуктуру, похожую на ту, что приведена ниже:


|- node_modules
|- public
  |- favicon.ico
  |- index.html
  |- manifest.json
|- src
  |- App.css
  |- App.js
  |- App.test.js
  |- index.css
  |- index.js
  |- logo.svg
  |- registerServiceWorker.js
|- .gitignore
|- package.json
|- package-lock.json
|- README.md

Инструмент create-react-app популярен, хорошо описан в документации и имеет хорошую поддержку в коммьюнити. Если вам интересно углубиться в детали, вы можете проверить репозиторий create-react-app на гитхабе и ознакомиться с руководствами пользователя.


Прямо сейчас можно избавиться от перечисленных ниже файлов, потому что в дальнейшем они нам не пригодятся:


  • App.css: хотя компонент App важен, стили будут определены в других компонентах;
  • App.test.js: тесты могут быть темой для другой статьи. Сейчас вам не придется их использовать.
  • logo.svg: в этой игре логотип React'a не используется.

Удаление этих файлов может привести к ошибке в случае, если вы попытаетесь запустить проект. Это легко исправить, удалив два "импорта" из файла ./src/App.js:


// удалите обе линии из ./src/App.js
import logo from './logo.svg';
import './App.css';

А также путем рефакторинга метода render():


// ... описание импортов и создание компонента через класс (опущено в коде)
render() {
  return (
    <div className="App">
      <h1>We will create an awesome game with React, Redux, and SVG!</h1>
    </div>
  );
}

// ... (закрывающая скобка и экспорт - аналогично, опущено)

Не забудьте сделать коммит!

Установка Redux и PropTypes


После разворачивания проекта и удаления из него ненужных файлов следует настроить Redux как единственный верный источник данных в вашем приложении. Также следует установить PropTypes, это поможет избежать некоторых распространенных ошибок. Оба инструмента могут быть установлены с помощью одной команды:


npm i redux react-redux prop-types

Как видите, вышеописанная команда включает третий NPM пакет: react-redux. Не рекомендуется использовать Redux напрямую с React. Пакет react-redux выполняет некоторые громоздкие для ручной обработки оптимизации производительности.


Настройка Redux и использование PropTypes


С помощью описанных пакетов вы можете настроить ваше приложение для использования Redux. Это несложно, вам лишь нужно создать контейнер (умный компонент), презентационный компонент (глупый компонент) и редьюсер. Разница между умным и глупым компонентами в том, что первый просто соединяет (connect) глупые компоненты с Redux. Третий создаваемый вами элемент, редьюсер, является основным компонентом в Redux store. Этот компонент отвечает за выполнение "экшенов" (действий), вызываемых различными событиями в вашем приложении, и за применение функций для изменения "стора" (источника данных) на основе этих действий.


Если вы не имеете представления обо всем этом, можете прочитать данную статью, где более подробно описаны компоненты (тупые) и контейнеры (умные) компоненты, а также открыть практическое руководство Redux для ознакомления с экшенами, редьюсером и стором. Несмотря на то, что изучение этих понятий настоятельно рекомендуется, вы можете продолжить изучение, не утруждая себя дополнительным чтением.

Удобнее начинать процесс с создания редьюсера, так как этот элемент не зависит от других (на самом деле, все наоборот). Для сохранения структурированности можно создать новый каталог под названием reducers, внутри него разместить src и добавить туда файл с именем index.js. Этот файл может содержать следующий исходный код:


const initialState = {
  message: `React и Redux легко интегрируются, не так ли?`,
};

function reducer(state = initialState) {
  return state;
}

export default reducer;

Таким образом ваш редьюсер просто инициализирует состояние приложения сообщением (message), что React и Redux легко интегрируются. Вскоре в этом файле вы начнете определять действия и обрабатывать их.


Затем вы можете провести рефакторинг компонента App чтобы вывести это сообщение пользователям. Самое время воспользоваться PropTypes. Для этого нужно открыть файл ./src/App.js и заменить его содержимое следующим текстом:


import React, {Component} from 'react';
import PropTypes from 'prop-types';

class App extends Component {
  render() {
    return (
      <div className="App">
        <h1>{this.props.message}</h1>
      </div>
    );
  }
}

App.propTypes = {
  message: PropTypes.string.isRequired,
};

export default App;

Как видите, с использованием PropTypes очень просто определить типы, которые ожидает ваш компонент. Вам нужно лишь установить PropTypes свойства компонента App с необходимыми параметрами. В сети есть шпаргалки (например, вот эта, эта и эта), в которых описаны способы создания базовых и расширенных PropTypes определений. Ознакомьтесь с ними, если нужно.


После определения исходного состояния вашего store (стора) и того, что ваш компонент App должен отображать, вам необходимо связать эти элементы вместе. Именно для этого необходимы контейнеры. Для создания контейнера в нашей структуре вам нужно создать каталог с именем сontainers внутри каталога src. После этого в новом каталоге создайте компонент с названием Game внутри файла Game.js. Этот контейнер будет использовать функцию connect из react-redux для передачи state.message к параметрам сообщения компонента App:


import { connect } from 'react-redux';

import App from '../App';

const mapStateToProps = state => ({
  message: state.message,
});

const Game = connect(
  mapStateToProps,
)(App);

export default Game;

Переходим к финальной стадии. Последним шагом к тому, чтобы связать все вместе будет рефакторинг файла ./src/index.js для инициализации Redux хранилища (store) и передачи его в контейнер Game (который затем получит сообщение и пошлет (прокинет) их в App). Следующий код показывает, как будет выглядеть файл ./src/index.js после рефакторинга:


import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import './index.css';
import Game from './containers/Game';
import reducer from './reducers';
import registerServiceWorker from './registerServiceWorker';

/* eslint-disable no-underscore-dangle */
const store = createStore(
    reducer, /* preloadedState, */
    window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(),
);
/* eslint-enable */

ReactDOM.render(
    <Provider store={store}>
        <Game />
    </Provider>,
    document.getElementById('root'),
);
registerServiceWorker();

Вы это сделали! Чтобы оценить, как все работает, в терминале перейдите в корень проекта и выполните npm start. Таким образом вы запустите приложение в режиме разработки (в dev-режиме), и оно откроется в установленном по умолчанию браузере.


Создание компонентов SVG с React


В этой серии вы оцените простоту создания SVG компонентов с использованием React. На самом деле практически нет разницы между созданием компонентов React с HTML или с SVG. Основным отличием является то, что в SVG вводятся новые элементы и эти элементы рисуются на холсте SVG.


Прежде чем приступить к созданию собственных компонентов с SVG и React, будет полезно вкратце ознакомиться с SVG.


Краткий обзор SVG


SVG — один из самых крутых и гибких веб-стандартов. SVG, что расшифровывается как Scalable Vector Graphics (масштабируемая векторная графика), представляет собой язык разметки, позволяющий разработчикам описывать двухмерную векторную графику. Язык SVG довольно похож на HTML. И тот, и другой являются языками разметки на основе XML и отлично работают совместно с другими вэб-стандартами, такими как CSS и DOM. Из этого следует, что правила CSS одинаково применимы как к SVG, так и к HTML, включая анимацию.


На протяжении этой серии с помощью React вами будут созданы более десятка компонентов SVG. Придется даже составлять (группировать) SVG элементы для формирования игровых объектов (пушка, стреляющая пушечными ядрами).


Более подробное изучение SVG невозможно в рамках нашей статьи, оно сделает ее слишком длинной. Если у вас есть желание лучше изучить язык разметки SVG, вы можете ознакомиться с учебником, представленным Mozilla, а также с изложенным в этой статье материалом о системе координат SVG.


Однако, прежде чем приступить к созданию собственных компонентов, важно усвоить несколько характеристик SVG. Во-первых, использование SVG в сочетании с DOM позволяет разработчикам "делать вещи". Использовать SVG с React очень просто.


Во-вторых, система координат SVG похожа на декартову плоскость, перевернутую вверх ногами. Соответственно, по умолчанию отрицательные значения по вертикали будут отображены выше оси Х. При этом горизонтальные значения расположены так же, как и в декартовой плоскости, то есть отрицательные значения находятся слева от оси Y. Такое поведение можно легко изменить, применив преобразование к холсту SVG. Однако будет лучше придерживаться параметров по умолчанию, дабы избежать путаницы среди разработчиков. Вы быстро привыкнете.


И наконец, следует помнить, что в SVG представлено множество новых элементов (таких, как circle, rect и path). Для использования этих элементов недостаточно просто определить их внутри HTML элемента. Сперва вам нужно определить svg элемент (ваш холст), где вы нарисуете все ваши SVG компоненты.


SVG, path элементы и кубические кривые Безье


Рисование элементов SVG может быть выполнено тремя способами. Во-первых, можно использовать базовые элементы, такие как rect, circle и line. Однако эти элементы не обладают особой гибкостью. Они позволяют вам рисовать простые фигуры в соответствии с их названием (прямоугольник, круг и линию).


Второй способ заключается в комбинировании базовых элементов для получения более сложных форм. Например, можно использовать прямоугольник (rect) с одинаковыми сторонами (получится квадрат) и две линии для того, чтобы нарисовать дом. Впрочем, такой подход все равно довольно сильно ограничивает.


Третий и наиболее гибкий способ — применять path элементы. Такой вариант позволяет разработчикам создавать довольно сложные формы. Рисование фигуры происходит посредством указания определенных команд браузеру. Например, чтобы нарисовать "L", вы можете создать path элемент, который содержит три команды:


  • M 20 20: команда браузеру переместить его "перо" в координаты X и Y, определенные после M (т. е. 20, 20);
  • V 80: команда браузеру нарисовать линию от предыдущей точки до позиции 80 по оси Y;
  • H 50: команда браузеру нарисовать линию от предыдущей точки до позиции 50 по оси X;

<svg>
  <path d="M 20 20 V 80 H 50" stroke="black" stroke-width="2" fill="transparent" />
</svg>

Path элементы принимают множество других команд. Одна из важнейших — команда кубических кривых Безье. Она позволяет вам добавить любые "сглаженные" кривые с применением двух опорных точек и двух контрольных точек.


"Кубическая кривая Безье для каждой точки принимает две контрольные точки. Таким образом, для создания кубической кривой Безье требуется указать три набора координат. Последний набор координат описывает точку, в которой заканчивается кривая. Два остальных набора — контрольные точки. [...]. По существу, контрольные точки описывают наклон вашей линии в данной точке. Функция Безье создает плавную кривую, направленную от наклона, установленного вами в начале линии к наклону, установленному в конце." — Mozilla Developer Network

Например, чтобы нарисовать "U", выполните следующее:


<svg>
  <path d="M 20 20 C 20 110, 110 110, 110 20" stroke="black" fill="transparent"/>
</svg>

В таком случае команды, переданные path элементу, указывают браузеру:


  1. Начать чертеж с точки 20,20;
  2. Координаты первой контрольной точки: 20, 110;
  3. Координаты второй контрольной точки: 110, 110;
  4. Координаты конечной точки кривой: 110 20;

Не отчаивайтесь, если вы до сих пор не поняли принцип работы кубических кривых Безье. У вас будет возможность попрактиковаться в этой серии. Кроме того, вы можете найти множество руководств об этой функции в Интернете, где всегда можно практиковаться в таких инструментах, как JSFiddle и Codepen.


Создание компонента Canvas


(речь идет не о тэге <canvas></canvas>, а о react-компоненте Canvas (по-русски Холст) — прим.переводчика)


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


Этот компонент характеризуется как презентационный (глупый). Таким образом, вы можете создать каталог components, внутри каталога ./src для хранения нового компонента и его "братьев и сестер" (соседних/дочерних элементов — прим.переводчика). Поскольку это будет ваш холст, сложно придумать для него более естественного названия, чем Canvas. Создайте новый файл с именем Canvas.jsx внутри каталога ./src/components/ и добавьте следующий код:


import React from 'react';

const Canvas = () => {
  const style = {
    border: '1px solid black',
  };
  return (
    <svg
      id="aliens-go-home-canvas"
      preserveAspectRatio="xMaxYMax none"
      style={style}
    >
      <circle cx={0} cy={0} r={50} />
    </svg>
  );
};

export default Canvas;

Перепишите компонент App, так чтобы он использовал компонент Canvas:


import React, {Component} from 'react';
import Canvas from './components/Canvas';

class App extends Component {
  render() {
    return (
      <Canvas />
    );
  }
}

export default App;

Если вы запустите проект (npm start) и проверите ваше приложение, вы увидите, что браузер рисует только четверть этого круга. Это происходит из-за того, что по умолчанию точка начала координат расположена в левом верхнем углу экрана. Кроме того, вы увидите, что svg элемент не занимает весь экран.


Для более интересного и удобного управления сделайте свой холст (<Canvas/>) пригодным для рисования на весь экран. Можете переместить его начало в центр оси Х и сделать ближе к низу (позже вы добавите к оригиналу свою пушку). Для выполнения обоих условий вам нужно изменить два файла: ./src/components/Canvas.jsx и ./src/index.css.


Можете начать с замены содержимого Canvas, применим следующий код:


import React from 'react';

const Canvas = () => {
  const viewBox = [window.innerWidth / -2, 100 - window.innerHeight, window.innerWidth, window.innerHeight];
  return (
    <svg
      id="aliens-go-home-canvas"
      preserveAspectRatio="xMaxYMax none"
      viewBox={viewBox}
    >
      <circle cx={0} cy={0} r={50} />
    </svg>
  );
};

export default Canvas;

В этой версии компонента вами был задан атрибут viewBox для тэга svg. Этот атрибут позволяет определить, что ваш холст и его содержимое должны соответствовать определенному контейнеру (в данном случае — внутренней области окна/браузера). Как вы видите, атрибут viewBox состоит из четырех чисел:


  • min-x: это значение определяет самую левую точку, которую могут видеть пользователи. Таким образом, чтобы получить отображение оси (и круга) в центре экрана, ширина экрана делится на 2 со знаком "-" (window.innerWidth / -2) для получения значения атрибута (min-x). Обратите внимание, что необходимо делить именно на (-2), чтобы ваш холст показывал одинаковое количество точек в обе стороны от начала координат.
  • min-y: определяется самая верхняя точка холста. Здесь нужно вычесть значение window.innerHeight из 100, чтобы задать определенную область (100 точек) от начала Y.
  • width и height: определяют количество точек по осям X и Y, которое пользователь будет видеть на своем экране.

Помимо определения атрибута viewBox в новой версии также задается атрибут под названием preserveAspectRatio. Вы использовали xMaxYMax none на нем для принудительного равномерного масштабирования вашего холста и его элементов.


(у меня установка preserveAspectRatio вызывала предупреждение от react — прим.переводчика)


После рефакторинга вашего холста вам необходимо добавить следующее правило в файл ./src/index.css:


/* ... body definition ... */

html, body {
  overflow: hidden;
  height: 100%;
}

Это делается для того, чтобы элементы (тэги) html и body скрыли (и отключили) прокрутку. Кроме того, элементы будут отображены на весь экран.


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


Создание компонента Sky (Небо)


После того, как на вашем холсте будет установлено разрешение на весь экран и начало координат будет размещено в его центре, можно приступить к созданию реальных игровых элементов. Начать можно с оформления фонового элемента для вашей игры — неба. Для этого создайте новый файл под названием Sky.jsx в каталоге ./src/components/ применив следующий код:


import React from 'react';

const Sky = () => {
  const skyStyle = {
    fill: '#30abef',
  };
  const skyWidth = 5000;
  const gameHeight = 1200;
  return (
    <rect
      style={skyStyle}
      x={skyWidth / -2}
      y={100 - gameHeight}
      width={skyWidth}
      height={gameHeight}
    />
  );
};

export default Sky;

Вам может быть интересно, почему для игры задается настолько большая площадь (ширина 5000 и высота 1200). В действительности, ширина в этой игре не имеет значения. Вам лишь нужно задать ее числом, достаточным для того, чтобы покрыть любой размер экрана.


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


Чтобы на холсте отображалось небо (компонент Sky), откройте файл Canvas.jsx в своем редакторе и исправьте его так:


import React from 'react';
import Sky from './Sky';

const Canvas = () => {
  const viewBox = [window.innerWidth / -2, 100 - window.innerHeight, window.innerWidth, window.innerHeight];
  return (
    <svg
      id="aliens-go-home-canvas"
      preserveAspectRatio="xMaxYMax none"
      viewBox={viewBox}
    >
      <Sky />
      <circle cx={0} cy={0} r={50} />
    </svg>
  );
};

export default Canvas;

Если сейчас вы проверите приложение (npm start), то вы увидите, что круг по-прежнему находится по центру внизу, а ваш фон стал синим.


Примечание: если вы добавите элемент Sky после элемента circle, вы больше не увидите ваш круг. Это объясняется тем, что SVG не поддерживает z-index. SVG определяет, который из элементов находится "выше", в соответствии с порядком их перечисления. То есть вам необходимо описать элемент circle после Sky, чтобы веб-браузеры отображали его поверх синего фона.


Создание компонента Ground (Земля)


После создания компонента Sky следует перейти к созданию компонента Ground. Для этого создайте новый файл Ground.jsx в каталоге ./src/components/ и добавьте следующий код:


import React from 'react';

const Ground = () => {
  const groundStyle = {
    fill: '#59a941',
  };
  const division = {
    stroke: '#458232',
    strokeWidth: '3px',
  };

  const groundWidth = 5000;

  return (
    <g id="ground">
      <rect
        id="ground-2"
        data-name="ground"
        style={groundStyle}
        x={groundWidth / -2}
        y={0}
        width={groundWidth}
        height={100}
      />
      <line
        x1={groundWidth / -2}
        y1={0}
        x2={groundWidth / 2}
        y2={0}
        style={division}
      />
    </g>
  );
};

export default Ground;

В этом элементе нет ничего фантастического. Это всего лишь композиция элемента rect (прямоугольник) и line (линии). Однако, как вы заметили, этот элемент также содержит константу со значением 5000, определяющую его ширину. Поэтому было бы неплохо создать файл для сохранения глобальных констант, подобных этой.


Раз уж мы пришли к такому выводу, создайте новый каталог с именем utils внутри каталога ./src/ и внутри этого нового каталога создайте файл с именем constants.js. На данный момент у нас всего одна константа для хранения в этом файле:


// очень широко, чтобы наверняка занять весь экран
export const skyAndGroundWidth = 5000;

После этого вы можете отрефакторить оба компонента и Sky и Ground, чтобы в них использовалась эта константа.


Не забудьте добавить компонент Ground к вашему холсту (помните, что вам нужно разместить его между небом и элементом окружности (т.е. между Sky и circle)). Если у вас имеются какие-либо сомнения касательно последних шагов, пожалуйста, взгляните на этот коммит.


Создание компонента Cannon (Пушка)


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


Как вы помните, кубические кривые Безье определяются четырьмя точками: начальной, конечной и двумя контрольными. Эти точки, заданные в свойстве d элемента path, выглядят следующим образом: M 20 20 C 20 110, 110 110, 110 20.


Чтобы в ходе создания кривых в вашем коде не было повторения подобных шаблонов, рекомендуется создать новый файл с именем formula.js в каталоге ./src/utils/ и добавить функцию, которая будет возвращать эту строку на основе некоторых параметров:


export const pathFromBezierCurve = (cubicBezierCurve) => {
  const {
    initialAxis, initialControlPoint, endingControlPoint, endingAxis,
  } = cubicBezierCurve;
  return `
    M${initialAxis.x} ${initialAxis.y}
    c ${initialControlPoint.x} ${initialControlPoint.y}
    ${endingControlPoint.x} ${endingControlPoint.y}
    ${endingAxis.x} ${endingAxis.y}
  `;
};

Это достаточно простой код, он просто извлекает (деструктурирует) четыре атрибута (initialAxis, initialControlPoint, endControlPoint, endAxis) из аргумента функции под названием cubicBezierCurve и передает их в шаблонный литерал, который построит необходимую кубическую кривую Безье.


Используя этот файл, вы можете начать создавать вашу пушку. Для поддержания структуры можно условно разделить пушку на две части: CannonBase (база) и CannonPipe (дуло).


Для описания CannonBase создайте новый файл CannonBase.jsx внутри ./src/components и добавьте в него следующий код:


import React from 'react';
import { pathFromBezierCurve } from '../utils/formulas';

const CannonBase = (props) => {
  const cannonBaseStyle = {
    fill: '#a16012',
    stroke: '#75450e',
    strokeWidth: '2px',
  };

  const baseWith = 80;
  const halfBase = 40;
  const height = 60;
  const negativeHeight = height * -1;

  const cubicBezierCurve = {
    initialAxis: {
      x: -halfBase,
      y: height,
    },
    initialControlPoint: {
      x: 20,
      y: negativeHeight,
    },
    endingControlPoint: {
      x: 60,
      y: negativeHeight,
    },
    endingAxis: {
      x: baseWith,
      y: 0,
    },
  };

  return (
    <g>
      <path
        style={cannonBaseStyle}
        d={pathFromBezierCurve(cubicBezierCurve)}
      />
      <line
        x1={-halfBase}
        y1={height}
        x2={halfBase}
        y2={height}
        style={cannonBaseStyle}
      />
    </g>
  );
};

export default CannonBase;

За исключением кубической кривой Безье в этом элементе нет ничего нового. В итоге браузер отобразит этот элемент как кривую с темно-коричневой (#75450e) границей и "зальет" ее площадь светло-коричневым цветом (#a16012).


Код для создания CannonPipe будет похож на код CannonBase. Отличия заключаются в других цветах и в том, что он передает другие точки в формулу pathFromBezierCurve, чтобы нарисовать трубу. Кроме того, этот элемент использует атрибут transform для имитации вращения пушки.


Для создания этого элемента добавьте следующий код в новый файл CannonPipe.jsx внутри каталога ./src/components/:


import React from 'react';
import PropTypes from 'prop-types';
import { pathFromBezierCurve } from '../utils/formulas';

const CannonPipe = (props) => {
  const cannonPipeStyle = {
    fill: '#999',
    stroke: '#666',
    strokeWidth: '2px',
  };
  const transform = `rotate(${props.rotation}, 0, 0)`;

  const muzzleWidth = 40;
  const halfMuzzle = 20;
  const height = 100;
  const yBasis = 70;

  const cubicBezierCurve = {
    initialAxis: {
      x: -halfMuzzle,
      y: -yBasis,
    },
    initialControlPoint: {
      x: -40,
      y: height * 1.7,
    },
    endingControlPoint: {
      x: 80,
      y: height * 1.7,
    },
    endingAxis: {
      x: muzzleWidth,
      y: 0,
    },
  };

  return (
    <g transform={transform}>
      <path
        style={cannonPipeStyle}
        d={pathFromBezierCurve(cubicBezierCurve)}
      />
      <line
        x1={-halfMuzzle}
        y1={-yBasis}
        x2={halfMuzzle}
        y2={-yBasis}
        style={cannonPipeStyle}
      />
    </g>
  );
};

CannonPipe.propTypes = {
  rotation: PropTypes.number.isRequired,
};

export default CannonPipe;

После этого сотрите элемент окружности с вашего холста и добавьте к нему CannonBase и CannonPipe. После рефакторинга холста вы получите следующий код:


import React from 'react';
import Sky from './Sky';
import Ground from './Ground';
import CannonBase from './CannonBase';
import CannonPipe from './CannonPipe';

const Canvas = () => {
  const viewBox = [window.innerWidth / -2, 100 - window.innerHeight, window.innerWidth, window.innerHeight];
  return (
    <svg
      id="aliens-go-home-canvas"
      preserveAspectRatio="xMaxYMax none"
      viewBox={viewBox}
    >
      <Sky />
      <Ground />
      <CannonPipe rotation={45} />
      <CannonBase />
    </svg>
  );
};

export default Canvas;

Запуск и проверка приложения отобразит вам следующую векторную графику:


image


Обучаем пушку прицеливаться


Наш процесс набирает обороты! Вы уже создали фоновые элементы (Sky и Ground) и свою пушку (CannonBase + CannonPipe). На данном этапе проблема заключается в отсутствии анимации. Следовательно, чтобы сделать что-то интересное, можно сосредоточиться на создании прицела. Для этого вы можете добавить слушатель событий onmousemove к вашему холсту и настроить его обновление при каждом возникновении события, т.е. каждый раз, когда пользователь двигает мышью, однако это скажется на производительности вашей игры.


Во избежание этой ситуации, вы можете установить интервал, по которому будете проверять последнюю позицию мыши для обновления угла наклона вашего элемента CannonPipe. В этом случае вы используете слушатель onmousemove с той лишь разницей, что эти события не будут вызывать частых ре-рендеров (перерисовок) всего холста. Будет обновляться только нужное свойство в вашей игре (угол наклона пушки), а заданный интервал будет использовать это свойство для запуска повторной обработки (путем обновления хранилища Redux).


В этом случае вам впервые потребуется использование Redux action (экшн, действие) для обновления состояния вашего приложения (а если быть точнее — угла наклона вашей пушки). Исходя из этого, вы создаете каталог с именем Actions внутри каталога ./src/. В этом новом каталоге вам нужно будет создать файл под названием index.js, содержащий следующий код:


export const MOVE_OBJECTS = 'MOVE_OBJECTS';

export const moveObjects = mousePosition => ({
  type: MOVE_OBJECTS,
  mousePosition,
});

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


После определения этого действия необходимо поправить редъюсер (файл index.js внутри ./src/reducers/):


import { MOVE_OBJECTS } from '../actions';
import moveObjects from './moveObjects';

const initialState = {
  angle: 45,
};

function reducer(state = initialState, action) {
  switch (action.type) {
    case MOVE_OBJECTS:
      return moveObjects(state, action);
    default:
      return state;
  }
}

export default reducer;

В новой версии этого файла проверяется экшн, и, если он имеет тип MOVE_OBJECTS, вызывается функция moveObjects. Вам все же следует задать эту функцию, но обратите внимание, что данная версия также определяет начальное состояние (initial state) вашего приложения, содержащее свойство под названием angle со значением 45. Именно под таким углом будет нацелена ваша пушка после запуска приложения.


Как вы уже заметили, функция moveObjects также является редъюсером. Для того, чтобы сохранить проект в должной мере поддерживаемым и структурированным, рекомендуется сохранить эту функцию в новом файле, поскольку в вашей игре будет присутствовать достаточное количество редъюсеров. Поэтому создайте файл moveObjects.js внутри ./src/reducers/ и добавьте к нему следующий код:


import { calculateAngle } from '../utils/formulas';

function moveObjects(state, action) {
  if (!action.mousePosition) return state;
  const { x, y } = action.mousePosition;
  const angle = calculateAngle(0, 0, x, y);
  return {
    ...state,
    angle,
  };
}

export default moveObjects;

Этот код достаточно прост, он лишь извлекает свойства x и y из mousePosition и передает их функции calculateAngle для получения нового угла наклона. Затем, наконец, он генерирует новое состояние (новый объект) с новым значением угла.


Вероятно, вы заметили, что не определили функцию calculateAngle в файле formula.js, верно? Изучение математического выражения для расчета угла наклона, основанного на двух точках, выходит за рамки данной серии, но если вам интересно, пройдите по ссылке на StackExchange, чтобы понять, как это происходит. Наконец, вам нужно добавить следующие функции в файл formula.js (./src/utils/formulas):


export const radiansToDegrees = radians => ((radians * 180) / Math.PI);

// https://math.stackexchange.com/questions/714378/find-the-angle-that-creating-with-y-axis-in-degrees
export const calculateAngle = (x1, y1, x2, y2) => {
  if (x2 >= 0 && y2 >= 0) {
    return 90;
  } else if (x2 < 0 && y2 >= 0) {
    return -90;
  }

  const dividend = x2 - x1;
  const divisor = y2 - y1;
  const quotient = dividend / divisor;
  return radiansToDegrees(Math.atan(quotient)) * -1;
};

Примечание: функция atan, выполняемая объектом Math, возвращает результат в радианах. Вам нужно преобразовать это значение в градусы. Для этого и применяется функция radiansToDegrees.


После того, как были определены новый экшн и новый редъюсер, вам необходимо ими воспользоваться. Поскольку управление состоянием вашей игры основано на Redux, нужно сопоставить action (экшн) moveObjects с props (свойствами) вашего компонента App. Это осуществляется путем рефакторинга контейнера Game. Открывайте файл Game.js (./src/containers) и заменяйте его содержимое следующим:


import { connect } from 'react-redux';

import App from '../App';
import { moveObjects } from '../actions/index';

const mapStateToProps = state => ({
  angle: state.angle,
});

const mapDispatchToProps = dispatch => ({
  moveObjects: (mousePosition) => {
    dispatch(moveObjects(mousePosition));
  },
});

const Game = connect(
  mapStateToProps,
  mapDispatchToProps,
)(App);

export default Game;

С этим маппингом (mapStateToProps и mapDispatchToProps) вы можете использовать необходимые данные в компоненте App в качестве props. Вам нужно открыть файл App.js (расположенный по адресу ./src/) и заменить его следующим:


import React, {Component} from 'react';
import PropTypes from 'prop-types';
import { getCanvasPosition } from './utils/formulas';
import Canvas from './components/Canvas';

class App extends Component {
  componentDidMount() {
    const self = this;
    setInterval(() => {
        self.props.moveObjects(self.canvasMousePosition);
    }, 10);
  }

  trackMouse(event) {
    this.canvasMousePosition = getCanvasPosition(event);
  }

  render() {
    return (
      <Canvas
        angle={this.props.angle}
        trackMouse={event => (this.trackMouse(event))}
      />
    );
  }
}

App.propTypes = {
  angle: PropTypes.number.isRequired,
  moveObjects: PropTypes.func.isRequired,
};

export default App;

Обратите внимание, что новая версия значительно отличается. Ниже представлен обобщенный список изменений:


  • componentDidMount: вы задали этот метод жизненного цикла (lifecycle method) для запуска интервала (setInterval), который вызывает экшн moveObjects;
  • trackMouse: задан для обновления свойства canvasMousePosition компонента App. Это свойство задействовано в экшне moveObjects. Заметьте, что это свойство не имеет отношения к позиции мыши над HTML-документом. Позиция мыши вычисляется относительно холста. Так же была определена функция canvasMousePosition.
  • render: необходим для передачи свойств угла (angle) и метода trackMouse к вашему компоненту Canvas. Этот компонент использует angle для обновления вращения вашей пушки и trackMouse для присоединения к элементу SVG в качестве слушателя событий.
  • App.propTypes: теперь здесь определяются два свойства: angle и moveObjects. Первое задает угол наклона, под которым целится ваша пушка. Второe, moveObjects — это функция, которая обновляет положение пушки во время срабатывания интервала.

Обновите ваш App компонент и добавьте следующую функцию в файл formula.js:


export const getCanvasPosition = (event) => {
  // mouse position on auto-scaling canvas
  // https://stackoverflow.com/a/10298843/1232793

  const svg = document.getElementById('aliens-go-home-canvas');
  const point = svg.createSVGPoint();

  point.x = event.clientX;
  point.y = event.clientY;
  const { x, y } = point.matrixTransform(svg.getScreenCTM().inverse());
  return {x, y};
};

Если вам интересно, для чего это понадобилось, загляните на StackOverflow.


Чтобы завершить разработку прицела для пушки, вам необходимо обновить последнюю часть кода — Canvas компонент. Откройте файл Canvas.jsx (расположенный в ./src/components) и замените его содержимое следующим:


import React from 'react';
import PropTypes from 'prop-types';
import Sky from './Sky';
import Ground from './Ground';
import CannonBase from './CannonBase';
import CannonPipe from './CannonPipe';

const Canvas = (props) => {
  const viewBox = [window.innerWidth / -2, 100 - window.innerHeight, window.innerWidth, window.innerHeight];
  return (
    <svg
      id="aliens-go-home-canvas"
      preserveAspectRatio="xMaxYMax none"
      onMouseMove={props.trackMouse}
      viewBox={viewBox}
    >
      <Sky />
      <Ground />
      <CannonPipe rotation={props.angle} />
      <CannonBase />
    </svg>
  );
};

Canvas.propTypes = {
  angle: PropTypes.number.isRequired,
  trackMouse: PropTypes.func.isRequired,
};

export default Canvas;

Отличия от прежней версии:


  • CannonPipe.rotation: теперь это свойство запрограммировано более гибко. Оно привязано к состоянию Redux store путем маппинга (тех самых функций (mapStateToProps и mapDispatchToProps) внутри функции connect, внутри контейнера Game — прим.переводчика).
  • svg.onMouseMove: этот слушатель событий добавлен на холст, чтобы определять положение мыши.
  • Canvas.propTypes: для полного счастья этому компоненту необходим angle и trackMouse.

Свершилось! Можете взглянуть через прицел на своего врага. Зайдите в терминале в корень проекта и введите npm start (если он еще не запущен). Затем откройте http://localhost:3000/ в веб-браузере и подвигайте мышью. Тем самым вы приведете в движение свою пушку.


И как вам, весело?!


Заключение и последующие шаги


В первой части этой серии вы познакомились с некоторыми важными темами, знание которых поможет вам в создании полной версии игры. Вы также использовали приложение create-react-app для быстрого старта вашего проекта и создали некоторые игровые элементы, такие как пушка, небо и земля. В конце концов, вы снабдили свою пушку прицелом. Обладая данными элементами, вы можете создать остальные элементы и сделать их анимированными.


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


Это будет круто!


Оставайтесь на связи!


От переводчика


Думаю, что иногда полезно отвлечься от "серьезных" проектов. А что думаете вы?


Перевод второй части

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Я бы хотел видеть переводы
88.06% Любых туториалов 59
8.96% Только серьезных туториалов (бизнес-задачи, не про «игрульки» и развлекухи) 6
2.99% Другое (предлагайте в комментариях) 2
Проголосовали 67 пользователей. Воздержались 13 пользователей.
Теги:reactreduxwebpacksvg
Хабы: CSS JavaScript HTML ReactJS
+12
24,3k 122
Комментарии 9
Похожие публикации
React разработчик
до 180 000 ₽2people ITМожно удаленно
Frontend разработчик
от 130 000 до 173 000 ₽ГК АРТИМожно удаленно
Frontend Developer
от 140 000 ₽HomeappМосква
Senior Frontend Developer
от 200 000 ₽HomeappМосква
Frontend разработчик
до 120 000 ₽Onlinetours.ruМожно удаленно
Лучшие публикации за сутки