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


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


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


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


Ранее в первой части


В первой серии вы использовали create-react-app для быстрого старта вашего React-приложения. Установили и настроили Redux для управления состоянием игры. Затем вы освоили использование SVG с компонентами React, создавая игровые элементы Sky, Ground, CannonBase, а также CannonPipe. И наконец, вы смонтировали прицел для вашей пушки, используя слушатель событий и интервал (setInterval) для запуска экшна Redux, меняющего угол наклона CannonPipe.


Этими упражнениями вы "прокачали" ваши навыки в создании игры (и не только) с помощью React, Redux и SVG.


Примечание: если по какой-то причине у вас нет кода, написанного в предыдущем разделе, просто скопируйте его из GitHub. После копирования следуйте дальнейшим инструкциям.


Создаем больше компонентов


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


После ознакомления с этим разделом вам будут представлены самые интересные темы этой серии. Они называются "Создание летающих объектов в случайном порядке " и "Использование анимации CSS для перемещения летающих объектов".


Создаем React-компонент Cannonball


Следующим вашим шагом будет создание элемента Cannonball (пушечное ядро). Отметим, что на данном этапе вы оставите этот элемент без движения. Но не переживайте! Вскоре (после создания остальных элементов) ваша пушка сможет многократно выстреливать ядрами и вы "поджарите" парочку пришельцев.


Для создания компонента добавьте новый файл CannonBall.jsx со следующим кодом:


import React from 'react';
import PropTypes from 'prop-types';

const CannonBall = (props) => {
  const ballStyle = {
    fill: '#777',
    stroke: '#444',
    strokeWidth: '2px',
  };
  return (
    <ellipse
      style={ballStyle}
      cx={props.position.x}
      cy={props.position.y}
      rx="16"
      ry="16"
    />
  );
};

CannonBall.propTypes = {
  position: PropTypes.shape({
    x: PropTypes.number.isRequired,
    y: PropTypes.number.isRequired
  }).isRequired,
};

export default CannonBall;

Как видите, для отображения компонента Cannonball на вашем холсте, вы должны задать ему координаты x и y (посредством передачи объекта, содержащего свойства x и y). Если вы не имеете большого опыта работы с Prop-types, возможно, здесь вы впервые столкнулись с PropTypes.shape. К счастью, эта функция не требует объяснений.


После создания компонента можете взглянуть на него. Для этого просто добавьте следующий тэг в SVG элемент компонента Canvas (также придется добавить import CannonBall from './CannonBall';):


<CannonBall position={{x: 0, y: -100}}/>

Только имейте ввиду, что если вы добавите его перед элементом, занимающим ту же позицию, вы не увидите его. Во избежание этого разместите его последним (сразу после <CannonBase />). После этого можете открыть игру в браузере для просмотра нового компонента.


Если вы забыли, как это делается, просто запустите npm start в корне проекта и затем откройте http://localhost:3000 в вашем браузере. Также не забудьте закоммитить код в репозиторий, прежде чем двигаться дальше.


Создаем компонент Current Score


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


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


@import url('https://fonts.googleapis.com/css?family=Joti+One');

/* другие правила ... */

Так вы загрузите в игру шрифт Joti One font из Google.


После этого создайте файл CurrentScore.jsx со следующим кодом внутри каталога ./src/components:


import React from 'react';
import PropTypes from 'prop-types';

const CurrentScore = (props) => {
  const scoreStyle = {
    fontFamily: '"Joti One", cursive',
    fontSize: 80,
    fill: '#d6d33e',
  };

  return (
    <g filter="url(#shadow)">
      <text style={scoreStyle} x="300" y="80">
        {props.score}
      </text>
    </g>
  );
};

CurrentScore.propTypes = {
  score: PropTypes.number.isRequired,
};

export default CurrentScore;

Примечание: если вы не настроили шрифт Joti One (либо предпочли ему какой-либо другой шрифт), необходимо соответствующим образом изменить этот код. Кроме того, этот шрифт будет использоваться в других компонентах, которые вы создадите, так что вам придется обновлять их тоже.


Как видите, для компонента CurrentScore необходимо лишь одно свойство (props): score(очки). Поскольку на данном этапе разработки в игре не ведется подсчет очков, для просмотра компонента задайте ему фиксированное значение. Для этого внутри компонента Canvas добавьте <CurrentScore score={15} /> как последний элемент внутри элемента svg. Также добавьте import, чтобы извлечь указанный компонент (import CurrentScore from './CurrentScore';).


Прямо сейчас вы не сможете оценить ваш новый компонент. Это связано с тем, что в компоненте используется фильтр shadow. Хотя такой фильтр не является чем-то обязательным, он сделает более привлекательным изображение в вашей игре. Кроме того, добавить тень к SVG элементам достаточно просто. Для этого необходимо дописать следующее вверху svg:


<defs>
  <filter id="shadow">
    <feDropShadow dx="1" dy="1" stdDeviation="2" />
  </filter>
</defs>

В итоге ваш компонент Canvas примет вид:


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';
import CannonBall from './CannonBall';
import CurrentScore from './CurrentScore';

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}
    >
      <defs>
        <filter id="shadow">
          <feDropShadow dx="1" dy="1" stdDeviation="2" />
        </filter>
      </defs>
      <Sky />
      <Ground />
      <CannonPipe rotation={props.angle} />
      <CannonBase />
      <CannonBall position={{x: 0, y: -100}}/>
      <CurrentScore score={15} />
    </svg>
  );
};

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

export default Canvas;

А вы получите такую вот картинку:


image


Неплохо, да?!


Создание React-компонента Flying Object (летающий объект)


Как насчет того, чтобы приступить к разработке летающих объектов с помощью React-компонентов? Эти объекты не описываются ни кругами, ни прямоугольниками. Обычно они состоят из двух закругленных частей (вершины и основания). Поэтому для их создания вы будете использовать два компонента: FlyingObjectBase (основание) и FlyingObjectTop (вершина).


Для определения формы одного из этих компонентов будет использоваться кубическая кривая Безье. Второй будет описываться эллипсом.


Можно начать с создания первого компонента, FlyingObjectBase, в новом файле FlyingObjectBase.jsx внутри директории ./src/components. Код для определения компонента представлен ниже:


import React from 'react';
import PropTypes from 'prop-types';

const FlyingObjectBase = (props) => {
  const style = {
    fill: '#979797',
    stroke: '#5c5c5c',
  };

  return (
    <ellipse
      cx={props.position.x}
      cy={props.position.y}
      rx="40"
      ry="10"
      style={style}
    />
  );
};

FlyingObjectBase.propTypes = {
  position: PropTypes.shape({
    x: PropTypes.number.isRequired,
    y: PropTypes.number.isRequired
  }).isRequired,
};

export default FlyingObjectBase;

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


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

const FlyingObjectTop = (props) => {
  const style = {
    fill: '#b6b6b6',
    stroke: '#7d7d7d',
  };

  const baseWith = 40;
  const halfBase = 20;
  const height = 25;

  const cubicBezierCurve = {
    initialAxis: {
      x: props.position.x - halfBase,
      y: props.position.y,
    },
    initialControlPoint: {
      x: 10,
      y: -height,
    },
    endingControlPoint: {
      x: 30,
      y: -height,
    },
    endingAxis: {
      x: baseWith,
      y: 0,
    },
  };

  return (
    <path
      style={style}
      d={pathFromBezierCurve(cubicBezierCurve)}
    />
  );
};

FlyingObjectTop.propTypes = {
  position: PropTypes.shape({
    x: PropTypes.number.isRequired,
    y: PropTypes.number.isRequired
  }).isRequired,
};

export default FlyingObjectTop;

Если вы не знаете принципов работы кубических кривых Безье, откройте предыдущую статью


Описанных действий достаточно для изображения нескольких летающих объектов, однако вам необходимо, чтобы они случайным образом появлялись в игре, и будет удобнее обрабатывать их как один элемент. Для этого добавьте к двум существующим файлам еще один FlyingObject.jsx:


import React from 'react';
import PropTypes from 'prop-types';
import FlyingObjectBase from './FlyingObjectBase';
import FlyingObjectTop from './FlyingObjectTop';

const FlyingObject = props => (
  <g>
    <FlyingObjectBase position={props.position} />
    <FlyingObjectTop position={props.position} />
  </g>
);

FlyingObject.propTypes = {
  position: PropTypes.shape({
    x: PropTypes.number.isRequired,
    y: PropTypes.number.isRequired
  }).isRequired,
};

export default FlyingObject;

Теперь для добавления в игру летающих объектов можно использовать лишь один компонент. Зацените, как это работает, обновив Canvas следующим образом:


// ... другие импорты
import FlyingObject from './FlyingObject';

const Canvas = (props) => {
  // ...
  return (
    <svg ...>
      // ...
      <FlyingObject position={{x: -150, y: -300}}/>
      <FlyingObject position={{x: 150, y: -300}}/>
    </svg>
  );
};

// ... propTypes и экспорт

image


Создаем компонент Heart (сердце)


Следующий компонент должен отображать на экране оставшиеся "жизни" игроков. Не придумать значок лучше, чем сердце — Heart. Итак, создаем файл под названием Heart.jsx:


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

const Heart = (props) => {
  const heartStyle = {
    fill: '#da0d15',
    stroke: '#a51708',
    strokeWidth: '2px',
  };

  const leftSide = {
    initialAxis: {
      x: props.position.x,
      y: props.position.y,
    },
    initialControlPoint: {
      x: -20,
      y: -20,
    },
    endingControlPoint: {
      x: -40,
      y: 10,
    },
    endingAxis: {
      x: 0,
      y: 40,
    },
  };

  const rightSide = {
    initialAxis: {
      x: props.position.x,
      y: props.position.y,
    },
    initialControlPoint: {
      x: 20,
      y: -20,
    },
    endingControlPoint: {
      x: 40,
      y: 10,
    },
    endingAxis: {
      x: 0,
      y: 40,
    },
  };

  return (
    <g filter="url(#shadow)">
      <path
        style={heartStyle}
        d={pathFromBezierCurve(leftSide)}
      />
      <path
        style={heartStyle}
        d={pathFromBezierCurve(rightSide)}
      />
    </g>
  );
};

Heart.propTypes = {
  position: PropTypes.shape({
    x: PropTypes.number.isRequired,
    y: PropTypes.number.isRequired
  }).isRequired,
};

export default Heart;

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


Пока же просто добавьте на холст одно сердце, чтобы убедиться, что это вообще работает, как надо. Откройте компонент Canvas и допишите:


<Heart position={{x: -300, y: 35}} />

На этом разработка элементов внутри svg должна закончиться. Также не забудьте добавить imoprt (import Heart from './Heart';).


Создаем кнопку "Начать игру" (Start Game)


В каждой игре должна быть кнопка запуска. Чтобы и у вашей игры была такая, добавьте файл StartGame.jsx и следующий код к нему:


import React from 'react';
import PropTypes from 'prop-types';
import { gameWidth } from '../utils/constants';

const StartGame = (props) => {
  const button = {
    x: gameWidth / -2, // половина ширины
    y: -280, // минус значит "над" (выше нуля)
    width: gameWidth,
    height: 200,
    rx: 10, // border радиус
    ry: 10, // border радиус
    style: {
      fill: 'transparent',
      cursor: 'pointer',
    },
    onClick: props.onClick,
  };

  const text = {
    textAnchor: 'middle', // центр
    x: 0, // центр относительно оси X
    y: -150, // 150 выше нуля (по оси Y)
    style: {
      fontFamily: '"Joti One", cursive',
      fontSize: 60,
      fill: '#e3e3e3',
      cursor: 'pointer',
    },
    onClick: props.onClick,
  };
  return (
    <g filter="url(#shadow)">
      <rect {...button} />
      <text {...text}>
        Tap To Start!
      </text>
    </g>
  );
};

StartGame.propTypes = {
  onClick: PropTypes.func.isRequired,
};

export default StartGame;

Поскольку вам не нужно одновременно несколько кнопок на экране, вы описали ее расположение статически (координатами x: 0 и y: -150). Помимо этого существуют другие два отличия между этим компонентом и описанным ранее:


  • Во-первых, компонент ожидает функцию onClick. По нажатию на кнопку эта функция вызывает Redux-экшн, который сообщает приложению, что нужно начать новую игру.
  • Во-вторых, компонент использует еще не определенную константу gameWidth. Эта константа будет описывать область, которую можно использовать. Любая другая область нужна лишь затем, чтобы развернуть приложение на весь экран.

Для определения константы gameWidth открывайте файл ./src/utils/constants.js и пишите:


export const gameWidth = 800;

После этого можете добавить компонент StartGame на ваш Canvas, дописав <StartGame onClick={() => console.log('Aliens, Go Home!')} /> в качестве последнего элемента в svg. И, как обычно, не забудьте добавить import (import StartGame from './StartGame';):


image


Создаем заголовок


Последним компонентом для разработки в этой серии будет Title (заголовок). У вашей игры уже есть название: "Aliens, Go Home!" (Пришельцы, проваливайте домой). Сделать его заголовком очень просто, создав файл (внутри каталога ./src/components) с кодом:


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

const Title = () => {
  const textStyle = {
    fontFamily: '"Joti One", cursive',
    fontSize: 120,
    fill: '#cbca62',
  };

  const aliensLineCurve = {
    initialAxis: {
      x: -190,
      y: -950,
    },
    initialControlPoint: {
      x: 95,
      y: -50,
    },
    endingControlPoint: {
      x: 285,
      y: -50,
    },
    endingAxis: {
      x: 380,
      y: 0,
    },
  };

  const goHomeLineCurve = {
    ...aliensLineCurve,
    initialAxis: {
      x: -250,
      y: -780,
    },
    initialControlPoint: {
      x: 125,
      y: -90,
    },
    endingControlPoint: {
      x: 375,
      y: -90,
    },
    endingAxis: {
      x: 500,
      y: 0,
    },
  };

  return (
    <g filter="url(#shadow)">
      <defs>
        <path
          id="AliensPath"
          d={pathFromBezierCurve(aliensLineCurve)}
        />
        <path
          id="GoHomePath"
          d={pathFromBezierCurve(goHomeLineCurve)}
        />
      </defs>
      <text {...textStyle}>
        <textPath xlinkHref="#AliensPath">
          Aliens,
        </textPath>
      </text>
      <text {...textStyle}>
        <textPath xlinkHref="#GoHomePath">
          Go Home!
        </textPath>
      </text>
    </g>
  );
};

export default Title;

Чтобы заголовок имел изогнутую форму, вы используете сочетание элементов path и textPath с кубической кривой Безье. Также вы задали заголовку статическое положение, как и для кнопки старта StartGame.


Чтобы заголовок появился на холсте, добавляйте <Title/> наsvgэлемент и не забывайте проimport(import Title from './Title';) сверху файлаCanvas.jsx`. Однако, если вы сейчас запустите приложение, то увидите, что новые элементы не отображаются на экране. Это из-за того, что в вашем приложении пока что недостаточно вертикального пространства.


Делаем игру responsive (отзывчивой)


Для того, чтобы изменить размеры в игре и сделать ее отзывчивой (адаптивной, то есть размер элементов игры изменяется при изменении окна браузера — прим.переводчика), необходимо сделать две вещи. Во-первых, прикрепить слушатель событий onresize к глобальному объекту window. Сделать это просто: откройте файл ./src/App.js и добавьте следующий код к методу componentDidMount():


window.onresize = () => {
  const cnv = document.getElementById('aliens-go-home-canvas');
  cnv.style.width = `${window.innerWidth}px`;
  cnv.style.height = `${window.innerHeight}px`;
};
window.onresize();

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


Второй момент: необходимо изменить на холсте свойство viewBox. Теперь, вместо того, чтобы рассчитать значение верхней точке по оси Y как 100 - window.innerHeight (если вы забыли, откуда взялась такая формула, смотрите первую часть и что высота viewBox равна высоте innerHeight окна window, вы используете следующее:


const gameHeight = 1200;
const viewBox = [window.innerWidth / -2, 100 - gameHeight, window.innerWidth, gameHeight];

В этом случае вы задаете значение высоты, равное 1200, что позволяет корректно показывать ваш новый заголовок. Кроме того, увеличение по вертикали пространство даст геймерам больше времени на уничтожение пришельцев: удобнее целиться и стрелять.
image


Позволяем пользователям начать игру


С учетом новых компонентов и новых размеров пришла пора задуматься о том, как дать пользователям возможность сыграть в игру. Для этого вы можете реорганизовать игру так, чтобы она запускалась по нажатию кнопки Start Game. После нажатия состояние игры должно значительно измениться. Однако для упрощения задачи можно начать с удаления с экрана компонентов Title и StartGame после того, как пользователь кликнет на кнопку.


Для этого создаем новый экшн, который будет обрабатываться редъюсером для изменения флага (флаг — некая переменная, у которой значения обычно true/false — прим.переводчика). Чтобы создать такой экшн, откройте файл ./src/actions/index.js и добавьте туда следующий код (прежний не трогайте!):


// ... MOVE_OBJECTS (прежний код про MOVE_OBJECTCS)
export const START_GAME = 'START_GAME';

// ... moveObjects (аналогично, прежний код с функцией moveObjects)

export const startGame = () => ({
  type: START_GAME,
});

После этого можно рефакторить файл ./src/reducers/index.js для обработки нового экшна. Вот новая версия:


import { MOVE_OBJECTS, START_GAME } from '../actions';
import moveObjects from './moveObjects';
import startGame from './startGame';

const initialGameState = {
  started: false,
  kills: 0,
  lives: 3,
};

const initialState = {
  angle: 45,
  gameState: initialGameState,
};

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

export default reducer;

Как видите, появился дочерний объект внутри initialState, который содержит три свойства игры:


  • started: флаг, указывающий, запущена игра или нет;
  • kills: количество сбитых летающих объектов;
  • lives: количество оставшихся "жизней";

Кроме того, в оператор switch вы добавили новый case. Этот case(который срабатывает, когда экшн типа START_GAME приходит к редъюсеру) вызывает функцию startGame. Эта функция включает флаг started внутри свойства gameStart. Кроме того, всякий раз, когда пользователь начинает игру заново, функция обнуляет количество килов (kills) и снова дает три жизни (lives).


Для реализации функции startGame создайте новый файл с названием startGame.js внутри директории ./src/reducers с кодом:


export default (state, initialGameState) => {
  return {
    ...state,
    gameState: {
      ...initialGameState,
      started: true,
    }
  }
};

Как видите, код в новом файле довольно прост. Он лишь возвращает новый объект состояния в Redux store (хранилище), где флаг начала (started) установлен в true и сбрасывает внутри свойства gameState все остальное. Так пользователи снова получают три жизни, а их килы kills обнуляются.


После того, как эта функция реализована, нужно передать ее в игру. Также необходимо передать новое свойство gameState. Для этого измените файл ./src/containers/Game.js следующим образом:


import { connect } from 'react-redux';
import App from '../App';
import { moveObjects, startGame } from '../actions/index';

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

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

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

export default Game;

Подводя итоги, отметим основные изменения в файле:
-mapStateToProps: так вы сообщили Redux'у, что компонент App "заботится" о свойстве gameState;
-mapDispatchToProps: Redux передает функцию startGame компоненту App для инициализации нового экшна.


Оба новых компонента (gameState и startGame) не используются напрямую компонентом App. Фактически их использует компонент Canvas, поэтому нужно передать их ему. Для этого откройте файл ./src/App.js и преобразуйте его так:


// ... иморты ...

class App extends Component {
  // ... constructor(props) ...

  // ... componentDidMount() ...

  // ... trackMouse(event) ...

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

App.propTypes = {
  angle: PropTypes.number.isRequired,
  gameState: PropTypes.shape({
    started: PropTypes.bool.isRequired,
    kills: PropTypes.number.isRequired,
    lives: PropTypes.number.isRequired,
  }).isRequired,
  moveObjects: PropTypes.func.isRequired,
  startGame: PropTypes.func.isRequired,
};

export default App;

Затем откройте файл ./src/components/Canvas.jsx и замените его код на следующий:


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';
import CurrentScore from './CurrentScore'
import FlyingObject from './FlyingObject';
import StartGame from './StartGame';
import Title from './Title';

const Canvas = (props) => {
  const gameHeight = 1200;
  const viewBox = [window.innerWidth / -2, 100 - gameHeight, window.innerWidth, gameHeight];
  return (
    <svg
      id="aliens-go-home-canvas"
      preserveAspectRatio="xMaxYMax none"
      onMouseMove={props.trackMouse}
      viewBox={viewBox}
    >
      <defs>
        <filter id="shadow">
          <feDropShadow dx="1" dy="1" stdDeviation="2" />
        </filter>
      </defs>
      <Sky />
      <Ground />
      <CannonPipe rotation={props.angle} />
      <CannonBase />
      <CurrentScore score={15} />

      { ! props.gameState.started &&
        <g>
          <StartGame onClick={() => props.startGame()} />
          <Title />
        </g>
      }

      { props.gameState.started &&
        <g>
          <FlyingObject position={{x: -150, y: -300}}/>
          <FlyingObject position={{x: 150, y: -300}}/>
        </g>
      }
    </svg>
  );
};

Canvas.propTypes = {
  angle: PropTypes.number.isRequired,
  gameState: PropTypes.shape({
    started: PropTypes.bool.isRequired,
    kills: PropTypes.number.isRequired,
    lives: PropTypes.number.isRequired,
  }).isRequired,
  trackMouse: PropTypes.func.isRequired,
  startGame: PropTypes.func.isRequired,
};

export default Canvas;

Как видите, новая версия организована так, что компоненты StartGame и Title появляются только в том случае, когда свойство gameState.started принимает значение false. Также вы спрятали летающие объекты (FlyingObject), пока пользователь не кликнет на кнопку Start Game.


Если вы запустите приложение сейчас (выполните npm start в терминале, если оно [приложение] до сих пор не запущено), вы увидите внесенные изменения. Этого недостаточно, чтобы полноценно сыграть в игру, но вы уже подходите к этой стадии.


Произвольно запускаем летающие тарелки


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


Первым делом необходимо определить, где они будут отображаться. Также нужно задать некоторый интервал, с которым они будут появляться и максимальное их количество. Для сохранения структуры определите константы, содержащие описанные условия. Откройте файл ./src/utils/constants.js и добавьте:


// ... не изменяйте skyAndGroundWidth gameWidth

export const createInterval = 1000;

export const maxFlyingObjects = 4;

export const flyingObjectsStarterYAxis = -1000;

export const flyingObjectsStarterPositions = [
  -300,
  -150,
  150,
  300,
];

В вышеописанных правилах указано, что летающие объекты будут появляться раз в секунду (1000 мс) и одновременно их будет не более четырех. Там также задано, что объекты возникают при значении -1000 по оси Y (flyingObjectsStarterYAxis). Последняя константа, добавленная в файл (flyingObjectsStarterPositions) определяет четыре значения по оси X, откуда могут прилететь враги. При создании летающего объекта произвольно выбирается одно из этих значений.


Для реализации функции, в которой задействованы эти константы, создайте файл под названием createFlyingObjects.jsв директории ./src/reducers с содержимым:


import {
  createInterval, flyingObjectsStarterYAxis, maxFlyingObjects,
  flyingObjectsStarterPositions
} from '../utils/constants';

export default (state) => {
  if ( ! state.gameState.started) return state; // игра не запущена

  const now = (new Date()).getTime();
  const { lastObjectCreatedAt, flyingObjects } = state.gameState;
  const createNewObject = (
    now - (lastObjectCreatedAt).getTime() > createInterval &&
    flyingObjects.length < maxFlyingObjects
  );

  if ( ! createNewObject) return state; // нет нужды создавать новые объекты в данный момент

  const id = (new Date()).getTime();
  const predefinedPosition = Math.floor(Math.random() * maxFlyingObjects);
  const flyingObjectPosition = flyingObjectsStarterPositions[predefinedPosition];
  const newFlyingObject = {
    position: {
      x: flyingObjectPosition,
      y: flyingObjectsStarterYAxis,
    },
    createdAt: (new Date()).getTime(),
    id,
  };

  return {
    ...state,
    gameState: {
      ...state.gameState,
      flyingObjects: [
        ...state.gameState.flyingObjects,
        newFlyingObject
      ],
      lastObjectCreatedAt: new Date(),
    }
  }
}

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


  1. Если игра не запущена (т.е. ! state.gameState.started), код просто возвращает текущее состояние без изменений.
  2. Если игра запущена, функция использует константы createInterval и maxFlyingObjects, чтобы определить, следует ли создать новый летающий объект или нет. Исходя из этой логики формируется значение константы createNewObject.
  3. Если константа createNewObject принимает значение true, эта функция использует Math.floor для получения случайно числа от 0 до 3 (Math.random() * maxFlyingObjects), чтобы определить, где возникнет летающий объект.
  4. Используя эти данные, функция создает новый новый объект с именемnewFlyingObject в заданных координатах.
  5. В итоге функция возвращает новый объект состояния (state) с новым летающим объектом и обновляет значение lastObjectCreatedAt.

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


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

function moveObjects(state, action) {
  const mousePosition = action.mousePosition || {
    x: 0,
    y: 0,
  };

  const newState = createFlyingObjects(state);

  const { x, y } = mousePosition;
  const angle = calculateAngle(0, 0, x, y);
  return {
    ...newState,
    angle,
  };
}

export default moveObjects;

В чем отличия новой версии редъюсера moveObjects:


  • Во-первых, он создает константу mousePosition, если она не передается в объект action. Это необходимо, поскольку в предыдущей версии редъюсер останавливался, если mousePosition ему не передавалась.
  • Во-вторых, он получает объект newState в результате выполнения createFlyingObjects; так по необходимости создаются новые летающие объекты.
  • Наконец, он возвращает новый объект на основе объекта newState, полученном в последнем шаге.

Перед рефакторингом компонентов App и Canvas для отображения летающих объектов, созданных с помощью нового кода, вам необходимо обновить файл ./src/reducers/index.js для добавления двух новых свойств в объект initialState:


// ... импорты ...

const initialGameState = {
  // ... другие начальные значения ...
  flyingObjects: [],
  lastObjectCreatedAt: new Date(),
};

// ... все остальное ...

Закончив с этим, все, что вам необходимо сделать — это добавить flyingObjects в объект PropTypes компонента App:


// ... импорты ...

// ... описание класса App ...

App.propTypes = {
  // ... other propTypes definitions ...
  gameState: PropTypes.shape({
    // ... other propTypes definitions ...
    flyingObjects: PropTypes.arrayOf(PropTypes.shape({
      position: PropTypes.shape({
        x: PropTypes.number.isRequired,
        y: PropTypes.number.isRequired
      }).isRequired,
      id: PropTypes.number.isRequired,
    })).isRequired,
    // ... other propTypes definitions ...
  }).isRequired,
  // ... other propTypes definitions ...
};

export default App;

А затем организуйте компонент Canvas так, чтобы он повторял это свойство для отображения летающих объектов. Обязательно замените статически заданные экземпляры FlyingObject следующими:


// ... импорты ...

const Canvas = (props) => {
  // ... определение констант ...
  return (
    <svg ... >
      // ... другие svg элементы  и react компоненты  ...

      {props.gameState.flyingObjects.map(flyingObject => (
        <FlyingObject
          key={flyingObject.id}
          position={flyingObject.position}
        />
      ))}
    </svg>
  );
};

Canvas.propTypes = {
  // ... другие определения PropTypes ...
  gameState: PropTypes.shape({
    // ... другие определения PropTypes ...
    flyingObjects: PropTypes.arrayOf(PropTypes.shape({
      position: PropTypes.shape({
        x: PropTypes.number.isRequired,
        y: PropTypes.number.isRequired
      }).isRequired,
      id: PropTypes.number.isRequired,
    })).isRequired,
  }).isRequired,
  // ... другие определения PropTypes ...
};

export default Canvas;

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


Примечание: если вы запустите приложение прямо сейчас и нажмете Start Game, вы увидите только один летающий объект. Это все из-за отсутствия каких-либо условий, препятствующих возникновению всех объектов в одной координате по оси Х. В следующем разделе вы научитесь двигать объекты по оси X. Это гарантирует вам и вашим пользователям видимость всех летающих объектов.


Используем CSS анимацию для перемещения летающих объектов.


Существует два способа для создания анимации летающих объектов. Первым и наиболее очевидным является использование JavaScript для изменения их положения. Хотя этот способ может показаться легко выполнимым, он приведет к неприемлимому снижению производительности игры. Второй и наиболее предпочтительный подход — использовать анимацию CSS. Преимущество этого подхода заключается в том, что он использует графический процессор для анимации элементов, что увеличивает производительность приложения.


Может показаться, что реализовать такой подход довольно сложно, однако вы увидите, что это не так. Самый сложный момент заключается в том, что вам потребуется еще один NPM-пакет для правильной интеграции анимаций CSS и React. Установите пакет styled-components.


Используя шаблонные строки (когда вы пишите текст в "бэктиках" — прим.переводчика) и возможности CSS, styled-components позволяют вам писать фактический CSS-код для стилизации компонентов. Также он убирает маппинг между компонентами и стилями — использование компонентов как низкоуровневой стилистической конструкции не может быть проще! — styled-components.

Чтобы установить этот пакет, остановите приложение (если оно запущено и работает) и выполните команду:


npm i styled-components

После установки замените код в компоненте FlyingObject (./src/components/FlyingObject.jsx) на следующий:


import React from 'react';
import PropTypes from 'prop-types';
import styled, { keyframes } from 'styled-components';
import FlyingObjectBase from './FlyingObjectBase';
import FlyingObjectTop from './FlyingObjectTop';
import { gameHeight } from '../utils/constants';

const moveVertically = keyframes`
  0% {
    transform: translateY(0);
  }
  100% {
    transform: translateY(${gameHeight}px);
  }
`;

const Move = styled.g`
  animation: ${moveVertically} 4s linear;
`;

const FlyingObject = props => (
  <Move>
    <FlyingObjectBase position={props.position} />
    <FlyingObjectTop position={props.position} />
  </Move>
);

FlyingObject.propTypes = {
  position: PropTypes.shape({
    x: PropTypes.number.isRequired,
    y: PropTypes.number.isRequired
  }).isRequired,
};

export default FlyingObject;

В новой версии вы обернули компоненты FlyingObjectBase и FlyingObjectTop в новый компонент под названием Move. Этот компонент является просто-напросто g элементом SVG,
который мы стилизуем для использования css анимации, описанной в moveVertically. Для того, чтобы больше узнать о преобразованиях и о том, как пользоваться компонентами styled-components, можете ознакомиться с официальной документацией и документом "Использование CSS анимаций" на сайте MDN.


Все это означает, что вместо добавления простых/статических летающих объектов вы добавляете элементы, которые преобразуются (правилом CSS) для того, чтобы перемещаться из начального положения (transform: translateY(0);) в самое нижнее положение в игре (transform: translateY(${gameHeight}px);).


Разумеется, придется добавить константу gameHeight в файл ./src/utils/constants.js. Также, поскольку вам необходимо обновить этот файл, вы можете заменить flyingObjectsStarterYAxis, чтобы движение объектов начиналось с позиции, невидимой для пользователя. При текущем значении объекты будут появляться прямо центре доступной области, что будет довольно странно для пользователя.


Чтобы внести необходимые изменения, откройте файл constants.js:


// сохраните другой код в файле неизменным ...

export const flyingObjectsStarterYAxis = -1100;

// не изменяйте позицию flyingObjectsStarterPositions ...

export const gameHeight = 1200;

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


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

function moveObjects(state, action) {
  const mousePosition = action.mousePosition || {
    x: 0,
    y: 0,
  };

  const newState = createFlyingObjects(state);

  const now = (new Date()).getTime();
  const flyingObjects = newState.gameState.flyingObjects.filter(object => (
    (now - object.createdAt) < 4000
  ));

  const { x, y } = mousePosition;
  const angle = calculateAngle(0, 0, x, y);
  return {
    ...newState,
    gameState: {
      ...newState.gameState,
      flyingObjects,
    },
    angle,
  };
}

export default moveObjects;

Как видите, новый код фильтрует свойство flyingObjects(gameState) так, чтобы удалить объекты, которые находятся на экране более 4000 мс (4 секунды).


Если сейчас вы перезапустите приложение и нажмете Start Game, вы увидите летающие объекты, движущиеся сверху вниз на SVG холсте. Также будет видно, что после того, как существующие объекты достигнут нижней части холста, появляются новые.


image


Заключение и что будет дальше


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


В третьей и заключительной части вы будете реализовывать недостающие в игре функции. Получится вот что: пушка начнет уничтожать пришельцев, появится счетчик "жизней" и "убийств" (kills). Также вы воспользуетесь auth0 и Socket.IO для реализации списка лидеров в реальном времени. На связи!

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