Разработка игры на React + SVG. Часть 3

в 15:06, , рубрики: express, javascript, node.js, React, ReactJS, redux, socket.io, svg

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 (
    <g filter="url(#shadow)">
      <rect {...button} />
      <text {...text}>
        Login to participate!
      </text>
    </g>
  );
};

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 (
    <svg ...>
      // ... другие элементы

      { ! props.gameState.started &&
      <g>
        // ... StartGame и Title компоненты
        <Login authenticate={signIn} />
      </g>
      }

      // ... flyingObjects.map
    </svg>
  );
};
// ... определение 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 (
    <svg ...>
      // ... другие элементы

      { ! props.gameState.started &&
      <g>
        // ... StartGame и Title
        <Leaderboard currentPlayer={leaderboard[6]} authenticate={signIn} leaderboard={leaderboard} />
      </g>
      }

      // ... flyingObjects.map
    </svg>
  );
};

// ... описание 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 (
    <g>
      <text filter="url(#shadow)" style={leaderboardTitle} x="-150" y="-630">Leaderboard</text>
      <rect style={style} x="-350" y="-600" width="700" height="330" />
      {
        props.currentPlayer && leaderboard.map((player, idx) => {
          const position = {
            x: -100,
            y: -530 + (70 * idx)
          };
          return <Rank key={player.id} player={player} position={position}/>
        })
      }
      {
        ! props.currentPlayer && <Login authenticate={props.authenticate} />
      }
    </g>
  );
};

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 (
    <g>
      <defs>
        <rect id={rectId} {...frameProperties} />
        <clipPath id={clipId}>
          <use xlinkHref={'#' + rectId} />
        </clipPath>
      </defs>
      <use xlinkHref={'#' + rectId} strokeWidth="2" stroke="black" />
      <text filter="url(#shadow)" style={textStyle} x={x - 200} y={y}>{props.player.rank}º</text>
      <image {...pictureProperties} />
      <text filter="url(#shadow)" style={textStyle} x={x - 60} y={y}>{props.player.name}</text>
      <text filter="url(#shadow)" style={textStyle} x={x + 350} y={y}>{props.player.maxScore}</text>
    </g>
  );
};

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 (
      <Canvas
        angle={this.props.angle}
        currentPlayer={this.props.currentPlayer}
        gameState={this.props.gameState}
        players={this.props.players}
        startGame={this.props.startGame}
        trackMouse={event => (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 (
    <svg ...>
      // ... другие элементы

      { ! props.gameState.started &&
      <g>
        // ... StartGame и Title
        <Leaderboard currentPlayer={props.currentPlayer} authenticate={signIn} leaderboard={props.players} />
      </g>
      }

      // ... flyingObjects.map
    </svg>
  );
};

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. Обновили элемент <Leaderboard />. Теперь ваши данные ближе к реальным: 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 (
      <Canvas
        // other props
        shoot={this.shoot}
      />
    );
  }
}

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 (
    <svg
      // ... другие свойства
      onClick={props.shoot}
    >
      // ... элементы defs, Sky и Ground

      {props.gameState.cannonBalls.map(cannonBall => (
        <CannonBall
          key={cannonBall.id}
          position={cannonBall.position}
        />
      ))}

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

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

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

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

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

Имея это ввиду, добавляем в файл ./src/utils/formulas.js следующую функцию:

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

export const checkCollision = (rectA, rectB) => (
  rectA.x1 < rectB.x2 && rectA.x2 > rectB.x1 &&
  rectA.y1 < rectB.y2 && rectA.y2 > rectB.y1
);

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

import { checkCollision } from '../utils/formulas';
import { gameHeight } from '../utils/constants';

const checkCollisions = (cannonBalls, flyingDiscs) => {
  const objectsDestroyed = [];
  flyingDiscs.forEach((flyingDisc) => {
    const currentLifeTime = (new Date()).getTime() - flyingDisc.createdAt;
    const calculatedPosition = {
      x: flyingDisc.position.x,
      y: flyingDisc.position.y + ((currentLifeTime / 4000) * gameHeight),
    };
    const rectA = {
      x1: calculatedPosition.x - 40,
      y1: calculatedPosition.y - 10,
      x2: calculatedPosition.x + 40,
      y2: calculatedPosition.y + 10,
    };
    cannonBalls.forEach((cannonBall) => {
      const rectB = {
        x1: cannonBall.position.x - 8,
        y1: cannonBall.position.y - 8,
        x2: cannonBall.position.x + 8,
        y2: cannonBall.position.y + 8,
      };
      if (checkCollision(rectA, rectB)) {
        objectsDestroyed.push({
          cannonBallId: cannonBall.id,
          flyingDiscId: flyingDisc.id,
        });
      }
    });
  });
  return objectsDestroyed;
};

export default checkCollisions;

В основном, этот код делает следующее:

  1. Он определяет массив под названием objectsDestroyed чтобы сохранять все уничтоженное.
  2. Выполняет итерацию по массиву flyingDiscs (с функцией forEach) для прямоугольного представления летающих дисков. Обратите внимание, поскольку для перемещения объектов вы используете анимацию CSS, вам нужно вычислять их положение по оси Y на основе их currentLifeTime.
  3. Выполняет итерацию над массивом cannonBalls (с функцией forEach) для прямоугольного представления пушечных ядер.
  4. Вызывает функцию checkCollisionс объектами в виде прямоугольников (ядрами и тарелками), чтобы определить, нужно ли их уничтожить (стереть). Если нужно, тогда они добавляются в массив objectsDestroyed, который возвращает эта функция.

Вам необходимо обновить файл moveObjects.js, чтобы использовать функцию следующим образом:

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

import checkCollisions from './checkCollisions';

function moveObjects(state, action) {
  // ... другие операторы и определения

  // единственное изменение в следующих трех строках - не может более бытб
  // константой, должен быть определен с помощью let
  let flyingObjects = newState.gameState.flyingObjects.filter(object => (
    (now - object.createdAt) < 4000
  ));

  // ... { x, y } константы и константа angle

  const objectsDestroyed = checkCollisions(cannonBalls, flyingObjects);
  const cannonBallsDestroyed = objectsDestroyed.map(object => (object.cannonBallId));
  const flyingDiscsDestroyed = objectsDestroyed.map(object => (object.flyingDiscId));

  cannonBalls = cannonBalls.filter(cannonBall => (cannonBallsDestroyed.indexOf(cannonBall.id)));
  flyingObjects = flyingObjects.filter(flyingDisc => (flyingDiscsDestroyed.indexOf(flyingDisc.id)));

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

export default moveObjects;

Здесь вы используете результат функции checkCollisions, чтобы убрать объекты из массивов cannonBalls и flyingObjects.

Теперь, когда ядра с тарелками "перекрывают" друг друга, новая версия редъюсера moveObjects удалит их из gameState. Этот экшн можете пронаблюдать в вэб-браузере.

Обновляем "жизни" и текущие очки

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

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

function moveObjects(state, action) {
  // ... до newState.gameState.flyingObjects.filter

  const lostLife = state.gameState.flyingObjects.length > flyingObjects.length;
  let lives = state.gameState.lives;
  if (lostLife) {
    lives--;
  }

  const started = lives > 0;
  if (!started) {
    flyingObjects = [];
    cannonBalls = [];
    lives = 3;
  }

  // ... x, y, angle, objectsDestroyed и так далее

  return {
    ...newState,
    gameState: {
      ...newState.gameState,
      flyingObjects,
      cannonBalls: [...cannonBalls],
      lives,
      started,
    },
    angle,
  };
}

export default moveObjects;

Эти новые строки просто сравнивают текущую длину массива flyingObjects с другой из исходного состояния, тем самым определяя, должен игрок потерять "жизнь" или нет. Такая стратегия работает, потому что вы добавляете этот код сразу после появления летающих объектов, которые остались в игре на 4 секунды ((now - object.createdAt) < 4000), и перед удалением объектов, которые сталкиваются.

Теперь, чтобы игроки видели свои "жизни", необходимо обновить компонент Canvas. Открывайте файл ./src/components/Canvas.jsx:

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

const Canvas = (props) => {
  // ... переменные gameHeight и viewBox 

  const lives = [];
  for (let i = 0; i < props.gameState.lives; i++) {
    const heartPosition = {
      x: -180 - (i * 70),
      y: 35
    };
    lives.push(<Heart key={i} position={heartPosition}/>);
  }

  return (
    <svg ...>
      // ... все остальные элементы
      {lives}
    </svg>
  );
};

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

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

Сделать такое усовершенствование довольно просто. Вам лишь нужно обновить файл ./src/reducers/moveObjects.js следующим образом:

// ... import statements

function moveObjects(state, action) {
  // ... все остальное

  const kills = state.gameState.kills + flyingDiscsDestroyed.length;

  return {
    // ...newState,
    gameState: {
      // ... другие props-ы
      kills,
    },
    // ... angle,
  };
}

export default moveObjects;

Затем в файле ./src/components.Canvas.jsx вам нужно заменить компонент CurrentScore (которому было присвоено значение 15) следующим:

<CurrentScore score={props.gameState.kills} />

Обновляем таблицу рейтинга

У нас отличные новости! Вам осталось обновить таблицу рейтинга, чтобы смело заявить, что вы разработали законченную игру с помощью React, Redux, SVG и CSS анимации. Вы увидите, что это будет быстро и безболезненно.

Для начала вам необходимо обновить ./server/index.js для сброса массива players. Вряд ли вы захотите выпускать игру с "фэйковыми" (не настоящими) игроками и "фэйковыми" результатами. Тогда открывайте файл и удаляйте этих игроков и их результаты. В конце у вас будет константа, определенная как:

const players = [];

После этого необходимо преобразовать компонент App. Откройте файл ./src/App.js и измените его:

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

// ... Auth0.configure

class App extends Component {
  constructor(props) {
    // ... super и this.shoot.bind(this)
    this.socket = null;
    this.currentPlayer = null;
  }

  // замените все содержимое метода componentDidMount 
  componentDidMount() {
    const self = this;

    Auth0.handleAuthCallback();

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

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

      self.props.loggedIn(self.currentPlayer);

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

      self.socket.on('players', (players) => {
        self.props.leaderboardLoaded(players);
        players.forEach((player) => {
          if (player.id === self.currentPlayer.id) {
            self.currentPlayer.maxScore = player.maxScore;
          }
        });
      });
    });

    setInterval(() => {
      self.props.moveObjects(self.canvasMousePosition);
    }, 10);

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

  componentWillReceiveProps(nextProps) {
    if (!nextProps.gameState.started && this.props.gameState.started) {
      if (this.currentPlayer.maxScore < this.props.gameState.kills) {
        this.socket.emit('new-max-score', {
          ...this.currentPlayer,
          maxScore: this.props.gameState.kills,
        });
      }
    }
  }

  // ... trackMouse, shoot, и метод render }

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

Подытоживая, перечислим изменения в этом компоненте:

  1. Вы определили два новых свойства в классе (socket и currentPlayer), так вы можете использовать их в разных методах.
  2. Удалили не настоящие значения максимальных очков, которые вы задали для эмуляции ивентов new-max-score.
  3. Сделали итерацию по массиву players (полученного от бэкэнда), чтобы установить игрокам правильный maxScore. То есть, если они повторно запустят игру, их maxScore сохранится.
  4. Задали хук на componentWillReceiveProps, чтобы фиксировать достижение игроками новых рекордов (новых maxScore). В этом случае игра запускает ивент new-max-score для обновления таблицы рейтинга.

Свершилось! Игра готова к прайм-тайму. Чтобы оценить все в действии, запускаем одновременно бэкэнд Socket.IO и приложение React со следующим кодом:

# запуск сервера в фоновом режиме
node ./server/index &

# запуск React-приложения
npm start

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

image

Заключение

В этих сериях вы применили множество замечательных технологий для создания простой и приятной игры. Вы использовали React для определения и управления элементами игры, SVG — (вместо HTML) для рендера этих элементов, Redux — для управления состоянием игры, и, наконец, использовали CSS анимацию для перемещения пришельцев по экрану. Кроме того, вам даже немного пригодился Socket.IO, чтобы сделать таблицу рейтинга в реальном времени, и Auth0 как система управления идентификацией.

Ауф! Вы прошли долгий путь и многому научились. Пришло время отдохнуть и немного пострелять!

Автор: maxfarseer

Источник

Поделиться

* - обязательные к заполнению поля