[Перевод] Разработка игры на React + SVG. Часть 3

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


(третья часть заключительная. В ней помимо завершения разработки непосредственно игры, рассмотрена авторизация с помощью Auth0 и простой realtime-сервер — прим.переводчика)


image


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


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


В предыдущих сериях


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


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


Во второй серии вы создали другие необходимые для игры элементы (Heart, FlyingObject и CannonBall), дали игрокам возможность запустить игру и заставили пришельцев летать (что они в итоге и делали, так?).


Несмотря на то, что все эти «фичи» очень круты, разработка игры все-таки не была закончена. Ваша пушка все еще не лупит ядрами, и нет алгоритма, который определит, что ядро попало в цель. Кроме того, значение компонента CurrentScore должно увеличиваться всякий раз, когда игрок сбивает очередного пришельца.


Убивать инопланетян и видеть, как накапливаются ваши очки — это, конечно, круто, но вы можете сделать игру еще более интересной. Для этого вам надо добавить функцию leaderboard — рейтинг лидеров. Игроки будут тратить еще больше своего времени на то, чтобы прокачать свой рейтинг до лидерского.


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


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


Реализация функции LeaderBoard (рейтинга)


Первое, что необходимо сделать, чтобы ваша игра по-настоящему стала игрой — реализовать функцию рейтинга. Это позволит игрокам находиться в системе, а игра будет считывать их максимальные очки и показывать их ранг.


Интегрируем React и Auth0


Чтобы Auth0 идентифицировал игроков, для начала вам необходим аккаунт в Auth0. Если у вас его еще нет, можете создать бесплатную учетную запись здесь.


После того, как заведете аккаунт, вам нужно лишь создать Auth0 Client для представления игры. Для этого перейдите на страницу «Clients» на панели управления Auth0 и нажмите кнопку «Create Client». На информационной панели будет форма, где вам нужно указать имя и тип клиента. Можете задать Aliens, Go Home! как имя и выбрать тип Single Page Web Application (ваша игра — это SPA на React). Затем нажмите «Create».


image


После этого вас перенаправит на вкладку «Quick Start» (быстрый старт) вашего клиента. Поскольку в этой статье вы итак научитесь интегрировать React и Auth0, данную вкладку можно проигнорировать. Вместо этого вам понадобится вкладка «Settings», так что открывайте ее.


На странице «Settings» (настройки) необходимо сделать три вещи. Первое: добавить значение http://localhost:3000 в поле под названием Allowed Callback URLs. Как поясняется на приборной панели (в дашборде), после аутентификации в Auth0, игрока будет переадресовывать на URL указанный в этом поле. Таким образом, если вы собираетесь публиковать игру в интернете, обязательно добавьте ее общедоступный URL-адрес (например, http://aliens-go-home.digituz.com.br).


После ввода в это поле всех ваших URL-адресов нажмите кнопку «Save» или клавиши ctrl + s (если у вас Макбук, нажмите command+s). Осталось два дела: скопировать значения из полей «Domain» и «Client ID». Но прежде чем их использовать, нужно немножко попрограммировать.


Для начала вам нужно ввести следующую команду в корне игры, чтобы установить пакет auth0-web


npm i auth0-web


Как видите, этот пакет облегчает интеграцию между Auth0 и SPA.


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


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

const Login = (props) => {
  const button = {
    x: -300,
    y: -600,
    width: 600,
    height: 300,
    style: {
      fill: 'transparent',
      cursor: 'pointer',
    },
    onClick: props.authenticate,
  };

  const text = {
    textAnchor: 'middle', // по центру
    x: 0, // по центру относительно X
    y: -440, // на 440 вверх
    style: {
      fontFamily: '"Joti One", cursive',
      fontSize: 45,
      fill: '#e3e3e3',
      cursor: 'pointer',
    },
    onClick: props.authenticate,
  };

  return (
    
      
      
        Login to participate!
      
    
  );
};

Login.propTypes = {
  authenticate: PropTypes.func.isRequired,
};

export default Login;


Созданный компонент — агностик с точки зрения того, что он выполняет при нажатии. Вы определяете этот экшн, добавляя его к компоненту Canvas. Итак, открываем Canvas.jsx и обновляем:


// ... другие импорты
import Login from './Login';
import { signIn } from 'auth0-web';

const Canvas = (props) => {
  // ... const definitions
  return (
    
      // ... другие элементы

      { ! props.gameState.started &&
      
        // ... StartGame и Title компоненты
        
      
      }

      // ... flyingObjects.map
    
  );
};
// ... определение propTypes и экспорт


Как видите, в новой версии вы импортировали компонент Login и функцию signIn из пакета auth0-web. Еще в коде появился компонент, который отображается только пока пользователи не начнут игру. Также вы прописали запуск функции signIn при нажатии кнопки авторизации.


Выполнив все это, настройте auth0-web с вашими свойствами Auth0 Client. Для этого откройте файл App.js:


// ... другие операторы import
import * as Auth0 from 'auth0-web';

Auth0.configure({
  domain: 'YOUR_AUTH0_DOMAIN', // домен
  clientID: 'YOUR_AUTH0_CLIENT_ID', // клиент id
  redirectUri: 'http://localhost:3000/',
  responseType: 'token id_token',
  scope: 'openid profile manage:points',
});

class App extends Component {
  // ... определение конструктора

  componentDidMount() {
    const self = this;

    Auth0.handleAuthCallback();

    Auth0.subscribe((auth) => {
      console.log(auth);
    });

    // ... setInterval и onresize
  }

  // ... trackMouse и render функции
}

// ... propTypes определение и оператор export


Примечание: вы должны заменить YOUR_AUTH0_DOMAIN и YOUR_AUTH0_CLIENT_ID значениями, скопированными из полей Domain и Client ID вашего Auth0-клиента. Помимо этого, при публикации игры вам необходимо также заменить значение redirectUri.

Улучшения в этом коде достаточно просты. Вот список:


  1. configure: эту функцию вы использовали для настройки пакета auth0-web с вашими Auth0 Client свойствами.
  2. handleAuthCallback: эту функцию вы вызывается в "хуке" жизненного цикла componentDidMount чтобы определить, возвращается ли игрок с Auth0 после аутентификации. Функция просто пытается извлечь токены из URL, и в случае успеха, выбирает профиль игрока и сохраняет все в localstorage.
  3. subscribe: эта функция применяется для определения аутентифицирован игрок или нет (true — если доступ, false — если нет).


Все, теперь в вашей игре используется Auth0 как служба управления идентификацией. Если сейчас вы запустите приложение (npm start) и откроете его в браузере (http://localhost:3000), то увидите кнопку логина. Кликнув на нее, вы перейдете на страницу Auth0 login, где можно авторизоваться.


После авторизации Auth0 снова перенаправит вас к игре, где функция handleAuthCallback вытащит ваши токены. Затем, когда вы сообщите вашему приложению выполнить console.log, сможете увидеть значение true в консоли браузера.


image


Создаем LeaderBoard (рейтинг)


Теперь, когда вы настроили Auth0 как систему управления идентификацией, вам необходимо создать компоненты, которые будут отображать рейтинг и максимальные очки игрока. Называются они так: leaderboard и rank. Потребуются именно два компонента, поскольку не так просто отобразить красиво данные игрока (например, набранные очки, имя, должность и аватарку). Это и не сложно, но для этого придется написать неплохой код. В общем, лепить из этого один компонент — не самый ловкий прием.


Поскольку пока у вас нет игроков, первым делом нужно определить некоторые «макетные данные» (так называемую «рыбу» — прим.переводчика), чтобы заполнить таблицу лидеров. Лучше всего это сделать в компоненте Canvas. Также, раз вы собираетесь обновить свой холст, можно еще и заменить компонент Login компонентом Leaderboard (одновременно добавите Login в Leaderboard):


// ... другие операторы import
// заменяем Login следующей строкой
import Leaderboard from './Leaderboard';

const Canvas = (props) => {
  // ... описание констант (рыбы)
  const leaderboard = [
    { id: 'd4', maxScore: 82, name: 'Ado Kukic', picture: 'https://twitter.com/KukicAdo/profile_image', },
    { id: 'a1', maxScore: 235, name: 'Bruno Krebs', picture: 'https://twitter.com/brunoskrebs/profile_image', },
    { id: 'c3', maxScore: 99, name: 'Diego Poza', picture: 'https://twitter.com/diegopoza/profile_image', },
    { id: 'b2', maxScore: 129, name: 'Jeana Tahnk', picture: 'https://twitter.com/jeanatahnk/profile_image', },
    { id: 'e5', maxScore: 34, name: 'Jenny Obrien', picture: 'https://twitter.com/jenny_obrien/profile_image', },
    { id: 'f6', maxScore: 153, name: 'Kim Maida', picture: 'https://twitter.com/KimMaida/profile_image', },
    { id: 'g7', maxScore: 55, name: 'Luke Oliff', picture: 'https://twitter.com/mroliff/profile_image', },
    { id: 'h8', maxScore: 146, name: 'Sebastián Peyrott', picture: 'https://twitter.com/speyrott/profile_image', },
  ];
  return (
    
      // ... другие элементы

      { ! props.gameState.started &&
      
        // ... StartGame и Title
        
      
      }

      // ... flyingObjects.map
    
  );
};

// ... описание propTypes и export 


В новой версии вы описали константу leaderboard, в которой содержится массив из вымышленных игроков. Эти игроки имеют следующие свойства: id, maxScore, name и picture. Затем внутри элемента svg вы добавили компонент leaderboard со следующими параметрами:


  • currentPlayer: определяет, кто играет на данный момент. Сейчас у вас есть вымышленные игроки, так что вы можете взглянуть, как все это работает. Цель передачи этого параметра — сделать так, чтобы играющий был подсвечен в таблице.
  • authenticate: тот же параметр, который вы ранее добавляли в компонент Login.
  • leaderboard: массив «фэйковых» игроков. Используется для отображения текущего рейтинга.


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


import React from 'react';
import PropTypes from 'prop-types';
import Login from './Login';
import Rank from "./Rank";

const Leaderboard = (props) => {
  const style = {
    fill: 'transparent',
    stroke: 'black',
    strokeDasharray: '15',
  };

  const leaderboardTitle = {
    fontFamily: '"Joti One", cursive',
    fontSize: 50,
    fill: '#88da85',
    cursor: 'default',
  };

  let leaderboard = props.leaderboard || [];
  leaderboard = leaderboard.sort((prev, next) => {
    if (prev.maxScore === next.maxScore) {
      return prev.name <= next.name ? 1 : -1;
    }
    return prev.maxScore < next.maxScore ? 1 : -1;
  }).map((member, index) => ({
    ...member,
    rank: index + 1,
    currentPlayer: member.id === props.currentPlayer.id,
  })).filter((member, index) => {
    if (index < 3 || member.id === props.currentPlayer.id) return member;
    return null;
  });

  return (
    
      Leaderboard
      
      {
        props.currentPlayer && leaderboard.map((player, idx) => {
          const position = {
            x: -100,
            y: -530 + (70 * idx)
          };
          return 
        })
      }
      {
        ! props.currentPlayer && 
      }
    
  );
};

Leaderboard.propTypes = {
  currentPlayer: PropTypes.shape({
    id: PropTypes.string.isRequired,
    maxScore: PropTypes.number.isRequired,
    name: PropTypes.string.isRequired,
    picture: PropTypes.string.isRequired,
  }),
  authenticate: PropTypes.func.isRequired,
  leaderboard: PropTypes.arrayOf(PropTypes.shape({
    id: PropTypes.string.isRequired,
    maxScore: PropTypes.number.isRequired,
    name: PropTypes.string.isRequired,
    picture: PropTypes.string.isRequired,
    ranking: PropTypes.number,
  })),
};

Leaderboard.defaultProps = {
  currentPlayer: null,
  leaderboard: null,
};

export default Leaderboard;


Не пугайтесь! На деле код довольно прост:


  1. Вы определяете константу leaderboardTitle, чтобы установить, как будет выглядеть заголовок таблицы рейтинга.
  2. Вы определяете константу dashedRectangle чтобы создать элемент rect, который послужит «контейнером» для таблицы.
  3. Вы вызываете функцию sort переменной props.leaderboard для упорядочивания ранга. После этого верхнюю строку таблицы займет игрок с наибольшим количеством очков, а нижнюю — с наименьшим. В случае равенства очков у игроков они упорядочиваются по именам.
  4. По результатам предыдущего действия вызывается функция map, чтобы добавить каждому игроку его ранг и добавить флаг currentPlayer. Этот флаг подсвечивает строку, на которой находится текущий игрок.
  5. В результате предыдущего шага (функции map) вы используете функцию filter, чтобы отсеять игроков, не входящих в ТОП-3. На деле же вы позволяете текущему игроку оставаться в конечном массиве, даже если он не входит в тройку лучших.
  6. Наконец, вы выполняете итерацию по фильтрованному массиву, чтобы показать элементы Rank, если игрок вошел в систему (props.currentPlayer && leaderboard.map), или же в противном случае отображается кнопка Login.


Переходим к заключительной стадии — создаем компонент Rank. Для этого создаем новый файл Rank.jsx рядом с файлом Leaderboard.jsx с кодом:


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

const Rank = (props) => {
  const { x, y } = props.position;

  const rectId = 'rect' + props.player.rank;
  const clipId = 'clip' + props.player.rank;

  const pictureStyle = {
    height: 60,
    width: 60,
  };

  const textStyle = {
    fontFamily: '"Joti One", cursive',
    fontSize: 35,
    fill: '#e3e3e3',
    cursor: 'default',
  };

  if (props.player.currentPlayer) textStyle.fill = '#e9ea64';

  const pictureProperties = {
    style: pictureStyle,
    x: x - 140,
    y: y - 40,
    href: props.player.picture,
    clipPath: `url(#${clipId})`,
  };

  const frameProperties = {
    width: 55,
    height: 55,
    rx: 30,
    x: pictureProperties.x,
    y: pictureProperties.y,
  };

  return (
    
      
        
        
          
        
      
      
      {props.player.rank}º
      
      {props.player.name}
      {props.player.maxScore}
    
  );
};

Rank.propTypes = {
  player: PropTypes.shape({
    id: PropTypes.string.isRequired,
    maxScore: PropTypes.number.isRequired,
    name: PropTypes.string.isRequired,
    picture: PropTypes.string.isRequired,
    rank: PropTypes.number.isRequired,
    currentPlayer: PropTypes.bool.isRequired,
  }).isRequired,
  position: PropTypes.shape({
    x: PropTypes.number.isRequired,
    y: PropTypes.number.isRequired
  }).isRequired,
};

export default Rank;


Не стоит пугаться и этого кода. В нем необычно только одно — вы добавляете к этому компоненту элемент clipPath и rect внутрь элемента defs для создания округлого портрета.
После всего этого можете перейти к приложению (http://localhost:3000/), чтобы увидеть вашу новую таблицу рейтинга.


image


Создаем таблицу ретинга «в реальном времени» с помощью Socket.IO


Отлично, теперь вы используете Auth0 как службу управления идентификацией и имеете все компоненты для отображения таблицы рейтинга. Что дальше? Все верно, вам необходим бэкэнд, способный отправлять события в реальном времени для обновления таблицы рейтинга.


Возможно, вы подумали, не сложно ли будет создать такой сервер (бэкэнд)? Нет, совсем нет. С помощью Socket.IO вы легко можете разработать эту фичу. Как бы то не было, вы захотите защитить этот сервис, верно? Для этого нужно создать API-интерфейс Auth0 для представления вашей службы.


Сделать это не так уж сложно. Просто зайдите на страницу API панели управления Auth0 и нажмите кнопку «Создать API». После этого необходимо заполнить форму с тремя полями:


  1. Имя API (name): нужно просто задать дружелюбное имя для запоминания, что представляет данный API. Так и называйте: «Пришельцы, возвращайтесь домой!».
    2. Идентификатор API (идентификатор): здесь рекомендуется указать конечный URL игры, но на деле можно вставить что угодно. Тем не менее, вводите https://aliens-go-home.digituz.com.br.
  2. Алгоритм записи токенов (Signing Algorithm) предлагает два варианта: RS256 и HS256. Вам лучше оставить это поле пустым (по умолчанию RS256). Если вам интересно, в чем разница, уточните здесь.


image


Когда заполните все поля, жмите «Create». Вас перенаправит на вкладку Быстрый старт внутри вашего нового API. Оттуда кликайте на вкладку «Scopes» (Области) и добавьте новую область с названием manage:points со следующим описанием: «Чтение и запись максимальных очков». Это хороший способ определения областей в Auth0 API-приложениях.


После того как добавите область, придется немного попрограммировать. Для реализации таблицы рейтинга в реальном времени сделайте следующее:


# создать директорию в текущей директории
mkdir server

# перейти в нее (в директорию)
cd server

# инициализировать NPM
npm init -y

# установить зависимости
npm i express jsonwebtoken jwks-rsa socket.io socketio-jwt

# создать файл
touch index.js


В новом файле добавьте код:


const app = require('express')();
const http = require('http').Server(app);
const io = require('socket.io')(http);
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

const client = jwksClient({
  jwksUri: 'https://YOUR_AUTH0_DOMAIN/.well-known/jwks.json' // ваш домен
});

const players = [
  { id: 'a1', maxScore: 235, name: 'Bruno Krebs', picture: 'https://twitter.com/brunoskrebs/profile_image', },
  { id: 'c3', maxScore: 99, name: 'Diego Poza', picture: 'https://twitter.com/diegopoza/profile_image', },
  { id: 'b2', maxScore: 129, name: 'Jeana Tahnk', picture: 'https://twitter.com/jeanatahnk/profile_image', },
  { id: 'f6', maxScore: 153, name: 'Kim Maida', picture: 'https://twitter.com/KimMaida/profile_image', },
  { id: 'e5', maxScore: 55, name: 'Luke Oliff', picture: 'https://twitter.com/mroliff/profile_image', },
  { id: 'd4', maxScore: 146, name: 'Sebastián Peyrott', picture: 'https://twitter.com/speyrott/profile_image', },
];

const verifyPlayer = (token, cb) => {
  const uncheckedToken = jwt.decode(token, {complete: true});
  const kid = uncheckedToken.header.kid;

  client.getSigningKey(kid, (err, key) => {
    const signingKey = key.publicKey || key.rsaPublicKey;

    jwt.verify(token, signingKey, cb);
  });
};

const newMaxScoreHandler = (payload) => {
  let foundPlayer = false;
  players.forEach((player) => {
    if (player.id === payload.id) {
      foundPlayer = true;
      player.maxScore = Math.max(player.maxScore, payload.maxScore);
    }
  });

  if (!foundPlayer) {
    players.push(payload);
  }

  io.emit('players', players);
};

io.on('connection', (socket) => {
  const { token } = socket.handshake.query;

  verifyPlayer(token, (err) => {
    if (err) socket.disconnect();
    io.emit('players', players);
  });

  socket.on('new-max-score', newMaxScoreHandler);
});

http.listen(3001, () => {
  console.log('listening on port 3001');
});


Прежде чем разбираться, что делает этот код, замените YOUR_AUTH0_DOMAIN вашим доменом Auth0 (тем, который вы добавили в файл App.js). Это значение находится в свойстве jwksUri.


Теперь, чтобы понять, как это работает, ознакомьтесь со следующим списком:


  1. express и socket.io: это просто экспресс-сервер, расширенный с помощью Socket.IO, чтобы научить его работать в режиме реального времени. Если вы ранее не пользовались Socket.IO, ознакомьтесь с их туториалом Get Started. Это достаточно просто.
  2. jwt и jwksClient: при аутентификации через Auth0 ваши игроки получат (помимо всего прочего) access_token в виде JWT (JSON Web Token). Так как вы используете алгоритм RS256, вам необходимо использовать пакет jwksClient, чтобы получить верный открытый ключ для проверки JWT.
  3. jwt.verify: как получите корректный ключ, используете эту функцию для декодирования и оценки JWT. Если все в порядке, вы просто отправляете список игроков в соответствии с запросом. Если же нет, то отключите (disconnect) клиент (socket).
  4. on('new-max-score', ...): наконец, вы присоедините функцию newMaxScoreHandler к событию new-max-score. Таким образом, всякий раз, когда вам нужно обновить максимальные баллы пользователя, вы вызываете это событие из React.


Оставшаяся часть кода интуитивно понятна. Вы можете сосредоточиться на интегрировании этого сервиса в игру.


Socket.IO и React


После создания вашего «realtime бэкэнд-сервиса» приступим к интегрированию его в React. Лучший способ использовать React и Socket.IO — это установить пакет socket.io-client. Для этого введите следующий код в корень приложения React:


npm i socket.io-client


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


export const LEADERBOARD_LOADED = 'LEADERBOARD_LOADED';
export const LOGGED_IN = 'LOGGED_IN';
// ... MOVE_OBJECTS и START_GAME ...

export const leaderboardLoaded = players => ({
  type: LEADERBOARD_LOADED,
  players,
});

export const loggedIn = player => ({
  type: LOGGED_IN,
  player,
});

// ... moveObjects и startGame ...


В новой версии определены действия, которые должны запускаться в два шага:


  1. LOGGED_IN: этим действием вы соединяете игру с бэкэндом, когда игрок входит в систему.
  2. LEADERBOARD_LOADED: этим действием вы обновляете «игроками» Redux store, когда бэкэнд отправляет список игроков.


Чтобы Redux отвечал на эти действия, откройте файл ./src/reducers/index.js и обновите его:


import {
  LEADERBOARD_LOADED, LOGGED_IN,
  MOVE_OBJECTS, START_GAME
} from '../actions';
// ... другие import операторы

const initialGameState = {
  // ... другие свойства состояния игры
  currentPlayer: null,
  players: null,
};

// ... определение initialState

function reducer(state = initialState, action) {
  switch (action.type) {
    case LEADERBOARD_LOADED:
      return {
        ...state,
        players: action.players,
      };
    case LOGGED_IN:
      return {
        ...state,
        currentPlayer: action.player,
      };
    // ... MOVE_OBJECTS, START_GAME, и default case
  }
}

export default reducer;


Теперь, когда в игре вызывается LEADERBOARD_LOADED, вы обновите Redux новым массивом игроков. Кроме того, всякий раз, когда игрок входит в систему, вы обновляете currentPlayer в хранилище.


Так, чтобы в игре использовались эти новые экшны, откройте файл ./src/containers/Game.js:


// ... другие операторы import
import {
  leaderboardLoaded, loggedIn,
  moveObjects, startGame
} from '../actions/index';

const mapStateToProps = state => ({
  // ... angle и gameState
  currentPlayer: state.currentPlayer,
  players: state.players,
});

const mapDispatchToProps = dispatch => ({
  leaderboardLoaded: (players) => {
    dispatch(leaderboardLoaded(players));
  },
  loggedIn: (player) => {
    dispatch(loggedIn(player));
  },
  // ... moveObjects и startGame
});

// ... операторы connect и export 


Выполнив это, можете подключить игру к realtime-сервису (вашему бэкэнду), чтобы обновлять таблицу рейтинга. Для этого откройте файл ./src/App.js и обновите его:


// ... другие операторы import
import io from 'socket.io-client';

Auth0.configure({
  // ... другие свойства
  audience: 'https://aliens-go-home.digituz.com.br',
});

class App extends Component {
  // ... конструктор

  componentDidMount() {
    const self = this;

    Auth0.handleAuthCallback();

    Auth0.subscribe((auth) => {
      if (!auth) return;

      const playerProfile = Auth0.getProfile();
      const currentPlayer = {
        id: playerProfile.sub,
        maxScore: 0,
        name: playerProfile.name,
        picture: playerProfile.picture,
      };

      this.props.loggedIn(currentPlayer);

      const socket = io('http://localhost:3001', {
        query: `token=${Auth0.getAccessToken()}`,
      });

      let emitted = false;
      socket.on('players', (players) => {
        this.props.leaderboardLoaded(players);

        if (emitted) return;
        socket.emit('new-max-score', {
          id: playerProfile.sub,
          maxScore: 120,
          name: playerProfile.name,
          picture: playerProfile.picture,
        });
        emitted = true;
        setTimeout(() => {
          socket.emit('new-max-score', {
            id: playerProfile.sub,
            maxScore: 222,
            name: playerProfile.name,
            picture: playerProfile.picture,
          });
        }, 5000);
      });
    });

    // ... setInterval и onresize
  }

  // ... trackMouse

  render() {
    return (
       (this.trackMouse(event))}
      />
    );
  }
}

App.propTypes = {
  // ... другие определения propTypes 
  currentPlayer: PropTypes.shape({
    id: PropTypes.string.isRequired,
    maxScore: PropTypes.number.isRequired,
    name: PropTypes.string.isRequired,
    picture: PropTypes.string.isRequired,
  }),
  leaderboardLoaded: PropTypes.func.isRequired,
  loggedIn: PropTypes.func.isRequired,
  players: PropTypes.arrayOf(PropTypes.shape({
    id: PropTypes.string.isRequired,
    maxScore: PropTypes.number.isRequired,
    name: PropTypes.string.isRequired,
    picture: PropTypes.string.isRequired,
  })),
};

App.defaultProps = {
  currentPlayer: null,
  players: null,
};

export default App;


Исходя из этого кода, вам необходимо:


  1. Сконфигурировать свойство audience в модуле Auth0.
  2. Выбрать профиль текущего игрока (Auth0.getProfile()) для создания константы currentPlayer и обновления хранилища (Redux store) (this.props.loggedIn(...)).
  3. Соединиться с сервисом реального времени (io('http://localhost:3001', ...)) с применением access_token игрока (Auth0.getAccessToken()).
  4. Прослушивать события players, создаваемые сервисом реального времени, для обновления Redux store (this.props.leaderboardLoaded(...)).


После этого, поскольку ваши игроки еще не могут убивать пришельцев, вы добавили временный код для имитации событий (events) new-max-score (новый рекорд). Во-первых, вы задаете новый maxScore равным 120, тем самым поместив авторизованного игрока на 5 место. Далее, по прошествии 5 секунд ((setTimeout(..., 5000)), вы создаете новый экшн c новым значением maxScore, равным 222, и игрок поднимается на вторую строку.


Помимо этих изменений вы передаете элементу Canvas два новых свойства: currentPlayer и players. Следовательно, откройте файл ./src/components/Canvas.jsx и обновите его:


// ... операторы import

const Canvas = (props) => {
  // ... константы gameHeight и viewBox 

  // удалить константу  leaderboard !!!!

  return (
    
      // ... другие элементы

      { ! props.gameState.started &&
      
        // ... StartGame и Title
        
      
      }

      // ... flyingObjects.map
    
  );
};

Canvas.propTypes = {
  // ... другие определения propTypes 
  currentPlayer: PropTypes.shape({
    id: PropTypes.string.isRequired,
    maxScore: PropTypes.number.isRequired,
    name: PropTypes.string.isRequired,
    picture: PropTypes.string.isRequired,
  }),
  players: PropTypes.arrayOf(PropTypes.shape({
    id: PropTypes.string.isRequired,
    maxScore: PropTypes.number.isRequired,
    name: PropTypes.string.isRequired,
    picture: PropTypes.string.isRequired,
  })),
};

Canvas.defaultProps = {
  currentPlayer: null,
  players: null,
};

export default Canvas;


В этом файле вы изменили следующее:


  1. Удалили константу leaderboard. Теперь вы загружаете ее с сервиса реального времени.
  2. Обновили элемент . Теперь ваши данные ближе к реальным: props.currentPlayer и props.players.
  3. Увеличили раздел propTypes, чтобы прописать, что компонент Canvas может использовать значения currentPlayer и players.


Сделано! Вы интегрировали вашу игру и сервис реального времени Socket.IO. Для общего тестирования (имеется в виду — проверки работоспособности приложения и сервера — прим.переводчика, а не прогона тестов) введите команды:


# перейдите в директорию с сервером
cd server

# запустите его в фоне
node index.js &

# перейдите обратно в директорию с игрой (cd .. = перейти на уровень выше)
cd ..

# старт приложения
npm start


Затем откройте игру в браузере: (http://localhost:3000). Вы увидите, что после авторизации вы займете пятую строку в рейтинге, а через пять секунд подниметесь на вторую:
image


Чего не хватает


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


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


В следующем разделе займемся непосредственно перечисленными задачами.


Стреляем ядрами


Чтобы игроки могли стрелять ядрами, вы добавите слушатель событий onClick на ваш Canvas. После этого по клику мыши ваш канвас будет вызывать Redux-экшн, чтобы добавить пушечное ядро в хранилище (то есть в состояние игры). Движение ядер будет обрабатываться редъюсером moveObjects.


Для реализации этой «фичи» можете создать экшн. Для этого откройте файл ./src/actions/index.js и добавьте следующий код:


// ... другие строковые константы

export const SHOOT = 'SHOOT';

// ... другие функции

export const shoot = (mousePosition) => ({
  type: SHOOT,
  mousePosition,
});


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


import {
  LEADERBOARD_LOADED, LOGGED_IN,
  MOVE_OBJECTS, SHOOT, START_GAME
} from '../actions';
// ... другие операторы import
import shoot from './shoot';

const initialGameState = {
  // ... другие свойства
  cannonBalls: [],
};

// ... определение initialState 

function reducer(state = initialState, action) {
  switch (action.type) {
    // другие case-операторы
    case SHOOT:
      return shoot(state, action);
    // ... операторы по умолчанию
  }
}


Как видите, в новой версии используется функция под названием shoot в момент, когда приходит экшн SHOOT. Вы все еще не описали эту функцию. Создайте файл с названием shoot.js в одной директории с редъюсером и добавьте следующий код:


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

function shoot(state, action) {
  if (!state.gameState.started) return state;

  const { cannonBalls } = state.gameState;

  if (cannonBalls.length === 2) return state;

  const { x, y } = action.mousePosition;

  const angle = calculateAngle(0, 0, x, y);

  const id = (new Date()).getTime();
  const cannonBall = {
    position: { x: 0, y: 0 },
    angle,
    id,
  };

  return {
    ...state,
    gameState: {
      ...state.gameState,
      cannonBalls: [...cannonBalls, cannonBall],
    }
  };
}

export default shoot;


Действие функции начинается с проверки, запущена ли игра. Если нет, то возвращается текущее состояние. В противном случае проверяется, вылетели ли из пушки уже два пушечных ядра. Чтобы игра была немного сложнее, вы ограничиваете количество ядер на экране. Если игрок выстрелил менее чем двумя шарами, функция использует calculateAngle для определения траектории нового ядра. Затем функция создает новый объект, представляющий собой пушечное ядро, и возвращает в хранилище (Redux store) новое состояние.


После того, как определите этот экшн и редьюсер для его обработки, вам придется обновить контейнер Game, чтобы предоставить экшн компоненту App. Итак, открывайте файл ./src/containers/Game.js:


// ... другие операторы import 
import {
  leaderboardLoaded, loggedIn,
  moveObjects, startGame, shoot
} from '../actions/index';

// ... mapStateToProps

const mapDispatchToProps = dispatch => ({
  // ... другие функции
  shoot: (mousePosition) => {
    dispatch(shoot(mousePosition))
  },
});

// ... connect и export


Далее необходимо обновить файл ./src/App.js:


// ... import statements and Auth0.configure

class App extends Component {
  constructor(props) {
    super(props);
    this.shoot = this.shoot.bind(this);
  }

  // ... componentDidMount and trackMouse definition

  shoot() {
    this.props.shoot(this.canvasMousePosition);
  }

  render() {
    return (
      
    );
  }
}

App.propTypes = {
  // ... other propTypes
  shoot: PropTypes.func.isRequired,
};

// ... defaultProps and export statements


Как видите, вы определяете новый метод в классе App для вызова функции shoot из props (то есть, появляется возможность «диспатчнуть» shoot — прим.переводчика) с помощью canvasMousePosition. Затем вы передаете этот новый метод компоненту Canvas. Вам все равно необходимо «прокачать» этот компонент, чтобы прикрепить этот метод к слушателю событий onClick элемента svg и сделать так, чтобы он «стрелял».


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

const Canvas = (props) => {
  // ... константы gameHeight и viewBox

  return (
    
      // ... элементы defs, Sky и Ground

      {props.gameState.cannonBalls.map(cannonBall => (
        
      ))}

      // ... CannonPipe, CannonBase, CurrentScore и так далее
    
  );
};

Canvas.propTypes = {
  // ... другие пропсы
  shoot: PropTypes.func.isRequired,
};

// ... операторы defaultProps и export 


Примечание: важно расположить cannonBalls.map перед CannonPipe, иначе ядра на экране «закроют» саму пушку.


Этих изменений достаточно для того, чтобы в игре появились и заняли исходное положение (x: 0, y: 0) пушечные ядра, а их траектория (angle) была верно определена. Проблема в том, что они все еще «неживые» (не передвигаются).


Чтобы привести их в движение, нужно добавил в файл ./src/utils/formulas.js две функции:


// ... другие функции

const degreesToRadian = degrees => ((degrees * Math.PI) / 180);

export const calculateNextPosition = (x, y, angle, divisor = 300) => {
  const realAngle = (angle * -1) + 90;
  const stepsX = radiansToDegrees(Math.cos(degreesToRadian(realAngle))) / divisor;
  const stepsY = radiansToDegrees(Math.sin(degreesToRadian(realAngle))) / divisor;
  return {
    x: x +stepsX,
    y: y - stepsY,
  }
};


Примечание: ознакомиться с тем, как работает формула, можно здесь.


В новом файле под названием moveCannonBalls.js вы будете использовать функцию calculateNextPosition. Создайте этот файл внутри директории ./src/reducers/ и добавьте код:


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

const moveBalls = cannonBalls => (
  cannonBalls
    .filter(cannonBall => (
      cannonBall.position.y > -800 && cannonBall.position.x > -500 && cannonBall.position.x < 500
    ))
    .map((cannonBall) => {
      const { x, y } = cannonBall.position;
      const { angle } = cannonBall;
      return {
        ...cannonBall,
        position: calculateNextPosition(x, y, angle, 5),
      };
    })
);

export default moveBalls;


В представленной функции вы делаете две важные вещи. Во-первых, вы использовали функцию filter, чтобы удалить cannonBalls (ядра), которые не попадают в определенную область. То есть вы убираете ядра, которые располагаются выше -800 по оси Y и те, которые сильно сместились влево (с координатой менее -500) и вправо (более 500).


Наконец, для использования этой функции необходимо преобразовать файл ./src/reducers/moveObjects.js следующим образом:


// ... другие операторы import 
import moveBalls from './moveCannonBalls';

function moveObjects(state, action) {
  if (!state.gameState.started) return state;

  let cannonBalls = moveBalls(state.gameState.cannonBalls);

  // ... mousePosition, createFlyingObjects, filter и так далее

  return {
    ...newState,
    gameState: {
      ...newState.gameState,
      flyingObjects,
      cannonBalls,
    },
    angle,
  };
}

export default moveObjects;


В новой версии этого файла вы просто увеличиваете предыдущий редъюсер moveObjects, чтобы использовать новую функцию moveBalls. Затем вы применяете результат этой функции для определения нового массива для свойства cannonBalls состояния игры gameState.


После того, как вы все это выполните, ваши игроки смогут лупить из пушки ядрами. Можете проверить это через вэб-браузер:


image


Распознаем попадания


Теперь, когда в игре можно стрелять ядрами из пушки, а пришельцы исправно вторгаются на Землю, самое время добавить алгоритм, который будет определять, что вы сбили очередного гада. С помощью этого алгоритма вы можете «стереть» ядро и летающую тарелку, которая была им сбита. Также это позволит вам работать над следующей «фичей»: увеличение очков.


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

© Habrahabr.ru