【翻譯】使用React、 Redux 和 SVG 開發遊戲(三)

MasterOfPuppets發表於2019-02-19

使用React、 Redux 和 SVG 開發遊戲(三)

本文翻譯自:Developing Games with React, Redux, and SVG - Part 3

轉載英文原作請註明原作者與出處。轉載本譯本請註明譯者譯者部落格

前情回顧

在上一部分,你建立了你需要的其他遊戲元素(Heart,FlyingObj和CannonBall)。然後讓玩家能夠點選按鈕來開始遊戲。你還使用CSS來讓你的飛行物運動起來。 儘管有這些功能已經很酷了,但是他們還不足以完成這個遊戲。你還需要讓你的加農炮發射炮彈並攻擊飛行物。你需要實現一個演算法來確認炮彈與飛行物之間的碰撞。能夠 擊落外星人的飛行物並讓分數上漲這聽起來已經很不錯了,但是你可以讓它更有趣。你可以製作一個排行榜,這會讓你的玩家全力遊戲去達到這個榜上的目標。 有了這些功能,你就開發完成一個完整的遊戲了。那麼,不浪費時間了,我們開始吧!

注意:不管什麼原因,如果你沒有前面的程式碼,你可以從這個Github倉庫克隆一個。

在你的React遊戲中實現排行榜

為了讓你的遊戲看起來更真實,你首先要做的事情應該是做一個排行榜功能。這個功能可以讓你的玩家註冊,然後你的遊戲可以追蹤他們的最高分並展示他們的等級。

整合React和Auth0

譯者:這篇文章的原作者釋出在Auth0這個網站。並且這個網站提供SSO(單點登入)的功能。作者在接下來這一段的思路就是連線到Auth0網的單點登入,從那邊登入獲取你遊戲的登入態。這一整個章節都是典型的SSO接入教程。這裡,如果能翻牆,可以直接按照教程來。如果沒有,我會尋找一種代替方案發布出來。

為了讓Auth0來管理你的玩家, 你需要先有一個Auth0的賬號,如果你沒有,請到這裡註冊:Auth0 註冊

註冊完成之後,你需要建立[Auth0應用](Auth0 Application)來代表你的遊戲。在Auth0的dashboard開啟應用程式頁面並點選Create Application按鈕。dashboard會給你一個表單讓你填應用的名字和型別。你可以起名就叫Aliens , Go Home !然後選擇型別為Single Page Application,最後點選建立。

【翻譯】使用React、 Redux 和 SVG 開發遊戲(三)

當你點選了這個按鈕,Dashboard會將你重定向到Quick Start頁籤。因為你將學習的是如何整合Auth0和React,所以你並不需要這個頁,你需要的是前往Settings頁。

在這裡你有三件事情要做,第一件事情是新增localhost://3000到回撥函介面的輸入框(Allowed Callback URLs)。這個就是玩家登陸成功後,Auth0回撥的你的遊戲地址。如果你以後將你的遊戲釋出到外網上,那你還需要把你釋出後的網址加進去。

輸入完所有的URLs之後,點選儲存按鈕。

最後你要做的兩件事就是複製你的應用ID和你的Auth0域名。不過在這之前你還需要敲一點點程式碼。

首先,你需要先安裝Auth0的web包:

npm install auth0-web@1.6.1
複製程式碼

譯者:這裡的版本號是我加上去的。現在auth0-web的版本已經更新到了2.2.0。是不相容更新。不加版本號安裝的是最新版本。

下一步是新增一個登陸按鈕,來讓你的玩家可以登陸並記錄分數。我們在./src/components建立一個新的元件叫做Login.jsx

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

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

	const text = {
		textAnchor: 'middle', // center
		x: 0, // center relative to X axis
		y: -440, // 440 up
		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.jsx

// ... 其他的匯入語句
import Login from './Login';
import { signIn } from 'auth0-web';

const Canvas = (props) => {
  // ... const definitions
  return (
    <svg ...>
      // ... 其他的元素
      { ! props.gameState.started &&
      <g>
        // ... StartGame and Title components
        <Login authenticate={signIn} />
      </g>
      }

      // ... 飛行物對映
    </svg>
  );
};
// ... propTypes 定義和匯出語句

複製程式碼

如你所見,在這個新的版本里你引入了登入元件和auth0-web提供的signin方法。我們點選按鈕的時候應該觸發signIn方法。

你要做的最後一件事是配置auth0-web。開啟App.js

// ... 其他匯入語句
import * as Auth0 from 'auth0-web';

Auth0.configure({
  domain: '你的Auth0的域名,你註冊的時候填的',
  clientID: '你的應用ID,在你的dashboard裡複製lai',
  redirectUri: 'http://localhost:3000/',
  responseType: 'token id_token',
  scope: 'openid profile manage:points',
});

class App extends Component {
  // ... constructor 定義

  componentDidMount() {
    const self = this;

    Auth0.handleAuthCallback();

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

    // ... setInterval 
  }

  // ... trackMouse and render functions
}

// ... propTypes definition and export statement
複製程式碼

注意:你必須填寫你自己的域和應用ID。這兩個引數你可以在你的Dashboard裡面複製過來。除此之外,如果你還想釋出你的遊戲,你需要把redirectUri的值也替換掉。

這個檔案發生的改變很簡單,下面是一個改變的列表:

  1. configure:你使用這個方法來配置了你的auth0-web
  2. handleAuthCallback:在componentDidMount生命週期中,你啟用這個方法來獲取使用者資訊。這個方法會傳送請求從Auth0調取使用者資訊並儲存在localStorage中。
  3. subscribe:這個方法用來判斷使用者是否已經驗證了。值為布林型別。

好了,你的遊戲現在已經介入了Auth0 的單點登入了。現在執行你的遊戲,你會看見登入按鈕並在點選之後會跳轉到Auth0的登入頁面。

當你成功登陸,Auth0會回撥你遊戲的地址。你之前在程式碼裡面寫了console.log所以現在我們能看到登陸狀態的任何改變。

【翻譯】使用React、 Redux 和 SVG 開發遊戲(三)

建立排行榜元件

現在你已經配置了Auth0來管理你的使用者安正,你需要做一個元件來展示當前使用者的最高分數,你可以分解地建立兩個元件:Leaderboard元件和Rank元件。因為優雅地展示出使用者資訊(比如UI高分,名字,位置和圖片)並不是那麼容易。不過也不是太難,只不過你要敲不少的程式碼。所以為了不讓一份程式碼檔案過於龐大笨拙,我們對它進行拆分。

你的遊戲現在還沒有任何玩家,為了實現功能,你需要製造一些假資料來構成你的排行榜。我們來修改你的Canvas元件。把其中的Login元件替換為Leaderboard元件。我們一會兒會把Login元件安裝到Leaderboard元件中去。

Canvas.jsx

// ... other import statements
// replace Login with the following line
import Leaderboard from './Leaderboard';

const Canvas = (props) => {
  // ... const definitions
  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 ...>
      // ... other elements

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

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

// ... propTypes definition and export statement
複製程式碼

在這個新版本中你定義了一個常量叫做learderboard。這是一個陣列,每個元素是一個物件。每個物件包含著一個玩家的資訊:id,最高分,名字和圖片。然後,在svg標籤裡,你新增了Leaderboard元件並傳入如下引數:

  • currentPlayer:這個引數定義了當前玩家是誰。現在暫時定義一個假的玩家資料,以看到效果。新增這個引數是讓你的元件將當前玩家進行高亮。
  • authenticate:這根我們之前傳給Login組建的引數是一樣的。
  • leaderboard:這個是我們指定的假的玩家資料。

現在,你應該開始製作Leaderboard元件了。在src/components中建立Leaderboard.jsx

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常量來設定Title的展示。
  2. 你定義了一個虛線邊框透明的長方體來作為排行榜的容器。
  3. 你呼叫了sort方法來對props.leaderboard進行排序。之後高分在上低分在下。相同分則會按照名字順序來排。
  4. 你呼叫了map方法對上一步的結果進行包裝,對當前使用者進行標記。你將會用這個標記來對當前使用者進行高亮顯示。
  5. 你呼叫了filter方法,對上一步的結果進行過濾,把前三名以外的玩家移除。不過,如果當前玩家不在前三名,就會在第四個位置顯示而不會被移除。
  6. 最後,你簡單地遍歷資料進行渲染。

最後你要做的就是製作Rank.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定義的圓形的組合元素,用來展示使用者的圖片。 現在,執行應用檢視效果吧!

【翻譯】使用React、 Redux 和 SVG 開發遊戲(三)

使用Socket.IO讓排行榜實時顯示

你已經讓排行榜顯示出來了,下一步做什麼呢?我們可以利用Socket.IO來讓排行榜實時顯示資料。這可能會讓你思考:建立一個實時的服務應該會很困難吧?不,不是的。使用Socket.IO,你可以瞬間完成這個功能。但是,在開始之前,你一定想要保護你的後端服務不被攻擊,對吧?你需要建立一個Auth0 API來代表你的服務。

做起來很容易。開啟Auth0 Dashboard 上面的 API 頁面點選CreateAPI按鈕,然後會蹦出來一個表單讓你填三樣東西:

  1. API的名字。你需要起一個有代表性的名字防止以後你忘了這個API是做什麼的。
  2. API驗證地址。先填這個吧:https://aliens-go-home.digituz.com.br
  3. 選擇一個演算法,RS256或者HS256。如果你感興趣:兩者區別

填寫完成之後會重定向到QuickStart頁面。現在點選Scopes標籤並建立一個scope。名字就叫manage:points,描述就填寫Read and Write MaxScore。這是為了讓你更好的記住你的API是做什麼的。寫完scope你就可以回去繼續編碼了。

# 建立一個服務資料夾
mkdir server

# 進入
cd server

# 初始化服務的npm
npm init -y

# 安裝依賴
npm i express jsonwebtoken jwks-rsa socket.io socketio-jwt

touch index.js

複製程式碼

編輯server/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,就跟App.js一樣。下面我們來了解一下這段程式碼:

  • ExpressSocket.io:這是一個簡單的Socket.IOExpress框架的結合搭建的服務。如果你沒有用過Socket.IO可以去看看官網基礎教程,非常簡單。
  • jwtjwksClient:在使用Auth0驗證的時候,你的玩家通過別的渠道獲得一個jwt(JSON Web Token)格式的access_token。因為你用了簡單的RS256演算法,你需要jwksClient這個包來獲取正確的公鑰來驗證JWT。如果你感興趣,可以到這裡檢視:auth0.com/docs/jwks
  • jwt.verify:在獲得了公鑰之後,你需要這個方法來解碼jwt並進行驗證。如果驗證通過了,就會傳送使用者列表,否則就會關閉連線。
  • on('new-max-score', ...):不管什麼時候你想要更新最大值,你需要在你的React中呼叫這個方法。

Socket.IO 和 React

建立了你的後臺服務之後,是時候使用它來實現你的遊戲了。最佳實踐是安裝the socket.io-client package

# 一定要進入你的React資料夾安裝而不是你的server資料夾。

npm i socket.io-client
複製程式碼

之後你的遊戲就會在使用者登入之後連線到你的後臺服務。如果使用者不登入,就不會顯示排行榜。你使用Redux來管理狀態,你還需要lia兩個action來保證你的Redux store的時效性。編輯./src/actions/index.js

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

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

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

// ... moveObjects and startGame ...
複製程式碼

這個版本定義了兩個action分別對應兩種情況:

  1. LOGGED_IN:當使用者登陸了,會觸發這個action去連結你的後臺實時服務。
  2. LEADERBOARD_LOADED:當後臺成功的獲取了使用者列表,這個action來更新React的store。

編輯./src/reducers/index.js來讓你的React響應這些actions:

import {
  LEADERBOARD_LOADED, LOGGED_IN,
  MOVE_OBJECTS, START_GAME
} from '../actions';
// ... other import statements

const initialGameState = {
  // ... other game state properties
  currentPlayer: null,
  players: null,
};

// ... initialState definition

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, and default cases
  }
}

export default reducer;
複製程式碼

現在使用者登入和使用者列表獲取成功時都會自動更新store了。接下來,為了讓你的遊戲應用這些actions,編輯/src/containers/Game.js

// ... other import statements
import {
  leaderboardLoaded, loggedIn,
  moveObjects, startGame
} from '../actions/index';

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

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

// ... connect and export statement

複製程式碼

現在,可以開始連結後臺服務了,編輯/src/App.js

// ... other import statements
import io from 'socket.io-client';

Auth0.configure({
  // ... other properties
  audience: 'https://aliens-go-home.digituz.com.br',
});

class App extends Component {
  // ... constructor

  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 and 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 = {
  // ... other propTypes definitions
  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. 配置了Auth0模組的audience屬性。
  2. 獲取當前使用者的資訊(Auth0.getProfile()),建立當前使用者的常量並更新了storethis.props.loggedIn(...)
  3. 使用玩家的access_token連結到後端服務:io('http://localhost:3001', ...)
  4. 監聽你的後臺服務觸發的玩家事件來更新storethis.props.leaderboardLoaded(...)

你的遊戲還沒有完成,你的玩家現在還不能擊落飛行物。你加入了一些臨時程式碼來模擬new-max-score事件。首先,你模擬了一個maxScore是120,這讓玩家拍到了第五。然後5秒鐘(setTimeout(..., 5000))之後你有設定maxScore為222。讓登陸的玩家排到了第二名。

除了這些修改,你還要傳給畫布元件一些新的屬性:currentPlayerplayers。編輯/src/components/Canvas.jsx

// ... import statements

const Canvas = (props) => {
  // ... gameHeight and viewBox constants

  // REMOVE the leaderboard constant !!!!

  return (
    <svg ...>
      // ... other elements

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

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

Canvas.propTypes = {
  // ... other propTypes definitions
  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獲取了。
  3. 增強了propTypes型別定義。

好了!你已經完成了排行榜部分,通過以下命令來啟動你的應用吧:

# move to the real-time service directory
cd server

# run it on the background
node index.js &

# move back to your game
cd ..

# start the React development server
npm start
複製程式碼

【翻譯】使用React、 Redux 和 SVG 開發遊戲(三)

完成缺失的部分

已經快完成了,但是還少一些東西:

  • 射擊的功能。
  • 碰撞檢測。
  • 更新生命值資訊和當前分數。
  • 更新排行榜。

那麼,在接下來的幾個段落,將會告訴你如何完成剩下的部分。

射擊

你需要新增一個點選事件來讓你的玩家可以發射加農炮彈。點選的時候出發action來給store新增一個炮彈。這個炮彈的軌跡將由moveObjects來維護。 編輯/src/actions/index.js來實現這個功能:

// ... other string constants

export const SHOOT = 'SHOOT';

// ... other function constants

export const shoot = (mousePosition) => ({
  type: SHOOT,
  mousePosition,
});
複製程式碼

然後你需要一個reducer來處理這個action。編輯/src/reducers/index.js

import {
  LEADERBOARD_LOADED, LOGGED_IN,
  MOVE_OBJECTS, SHOOT, START_GAME
} from '../actions';
// ... other import statements
import shoot from './shoot';

const initialGameState = {
  // ... other properties
  cannonBalls: [],
};

// ... initialState definition

function reducer(state = initialState, action) {
  switch (action.type) {
    // other case statements
    case SHOOT:
      return shoot(state, action);
    // ... default statement
  }
}
複製程式碼

這裡引入了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;
複製程式碼

這個函式在最開始檢查了遊戲的開始狀態。如果遊戲沒有開始,簡單地返回當前的state。如果開始了,它會檢查是否存已經在兩個炮彈了。你限制了炮彈的個數來讓遊戲稍微難一點兒。如果現存炮彈少於2個,這個函式會計算一個炮彈軌跡併發射一枚新的炮彈,如果不少於兩個則不能發射。最後函式建立了一個新的物件代表新建立的炮彈並返回一個新的store。

在定義了這個函式之後,你還需要更新Game.js的程式碼來向App元件提供action。更新/src/containers/Game.js檔案:

// ... other import statements
import {
  leaderboardLoaded, loggedIn,
  moveObjects, startGame, shoot
} from '../actions/index';

// ... mapStateToProps

const mapDispatchToProps = dispatch => ({
  // ... other functions
  shoot: (mousePosition) => {
    dispatch(shoot(mousePosition))
  },
});

// ... connect and 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裡定義了一個新的方法:使用canvasMousePosition作為引數來呼叫shootdispatcher。然後你把這個新的方法傳給了畫布元件。所以你還要在畫布元件裡新增點選事件來觸發這一系列的流程。

// ... other import statements
import CannonBall from './CannonBall';

const Canvas = (props) => {
  // ... gameHeight and viewBox constant

  return (
    <svg
      // ... other properties
      onClick={props.shoot}
    >
      // ... defs, Sky and Ground elements

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

      // ... CannonPipe, CannonBase, CurrentScore, etc
    </svg>
  );
};

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

// ... defaultProps and export statement
複製程式碼

注意,一定要在加農炮元件之前新增加農炮元件,防止炮彈蓋住大炮。

這些修改足矣讓你在初始位置(0,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/建立這個js檔案:

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;
複製程式碼

在這個檔案的程式碼裡,你做了件重要的事情。你將可用區域外的炮彈都移除掉了。接下來要做的就是重構/src/reducers/moveObjects.js來使用這個函式:

// ... other import statements
import moveBalls from './moveCannonBalls';

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

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

  // ... mousePosition, createFlyingObjects, filter, etc

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

export default moveObjects;
複製程式碼

在這個新版本中,你簡單地增強了這個moveObjects來保證能建立新的炮彈。然後你用這個函式更新了儲存炮彈的陣列。現在你可以執行你的遊戲看一下,已經可以發射炮彈了!

【翻譯】使用React、 Redux 和 SVG 開發遊戲(三)

碰撞檢測

現在你的玩家已經可以發射炮彈了,下一步我們來做碰撞檢測。通過這個演算法,你可以把碰到一起的炮彈和飛行物移除。這也將支援你完成下面的任務:漲分。

實現這個功能的一個比較好的戰略是,把炮彈和飛行物都想象成長方形。把他們當做長方形來處理會讓事情變得很容易。在這個遊戲裡你不需要太高的精度。抱著這樣的想法,我們在/src/utils/formulas.js中新增這樣一個函式:

// ... other functions

export const checkCollision = (rectA, rectB) => (
  rectA.x1 < rectB.x2 && rectA.x2 > rectB.x1 &&
  rectA.y1 < rectB.y2 && rectA.y2 > rectB.y1
);
複製程式碼

你也看到了,把他們當做長方形處理,碰撞檢測的程式碼條件很少也很簡單。下面在/src/reducers中建立一個checkCollisions.js來使用這個函式:

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物件,給每個飛行物建立一個矩形檢測區域。既然你是用css來移動他們的,你就應該通過currentLifeTime來計算他們Y軸上的位置。X軸不會變。
  3. 遍歷cannonBalls物件,給每個炮彈建立矩形檢測區域。
  4. 呼叫checkCollision對3和4進行計算。碰撞的消除。並把消除的物件新增進objectsDestroyed

最後你需要更新moveObjects.js的程式碼來使用這個函式:

// ... import statements

import checkCollisions from './checkCollisions';

function moveObjects(state, action) {
  // ... other statements and definitions

  // the only change in the following three lines is that it cannot
  // be a const anymore, it must be defined with let
  let flyingObjects = newState.gameState.flyingObjects.filter(object => (
    (now - object.createdAt) < 4000
  ));

  // ... { x, y } constants and angle constant

  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的結果從陣列中移除了炮彈和飛行物。並且會從gameState中移除這兩個碰撞的元素。你可以在你的瀏覽器裡試一下。

更新生命值和當前分數

每當一個飛行物調到地上,你必須要減掉玩家的宣告值。並且你需要在宣告值為0時結束遊戲。你需要更改兩個檔案來實現,先編輯/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) {
  // ... code until 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, etc ...

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

export default moveObjects;
複製程式碼

這次的修改通過用當前flyingObjects的長度與原來的長度進行對比,來判斷玩家是否會丟失生命值。你把這個程式碼緊接著放在了建立球的程式碼後面,所以他能準確地監控。

現在你需要更新Canvas.jsx來使它生效:

// ... other import statements
import Heart from './Heart';

const Canvas = (props) => {
  // ... gameHeight and viewBox constants

  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 ...>
      // ... all other elements

      {lives}
    </svg>
  );
};

// ... propTypes, defaultProps, and export statements
複製程式碼

改完這些東西,你基本就快要完成了。現在如果有飛行物落地,就會減少生命值,生命值為0就會結束遊戲。現在,還差一件事情,就是增加分數。其實也很簡單,你需要像這樣編輯/src/reducers/moveObjects.js

// ... import statements

function moveObjects(state, action) {
  // ... everything else

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

  return {
    // ...newState,
    gameState: {
      // ... other props
      kills,
    },
    // ... angle,
  };
}

export default moveObjects;
複製程式碼

然後修改畫布元件。把CurrentScore中的硬編碼15,給替換掉。

更新排行榜

好訊息!這是最後一件事了!編寫完更新排行榜的功能,你就完成啦。

首先你需要編輯/server/index.js來檢視玩家列表。你肯定不想讓你的玩家都是假的。所以,先把模擬的玩家資料都刪除掉吧:

const players = [];
複製程式碼

然後你需要重構App.js

// ... import statetments

// ... Auth0.configure

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

  // replace the whole content of the componentDidMount method
  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,
      };

      this.props.loggedIn(self.currentPlayer);

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

      self.socket.on('players', (players) => {
        this.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, and render method
}

// ... propTypes, defaultProps, and export statement
複製程式碼

下面是本次修改的總結:

  1. 你定義了兩個新的屬性叫做socketcurrentPlayer。你可以在不同的方法裡使用它們。
  2. 你一出了為了模擬new-max-score建立的分數們。
  3. 你遍歷了從Socket.IO獲得的玩家列表來渲染元件。
  4. 你定義了componentWillReceiveProps生命週期函式來檢查玩家是否達到了最高分,如果達到了,就更新最高分的記錄。

好了!完成了!試試你的遊戲吧!

node ./server/index &

npm start
複製程式碼

這時你建立兩個不同的賬戶,就會有一個真實的排行榜了。

【翻譯】使用React、 Redux 和 SVG 開發遊戲(三)

總結

這一系列下來,你使用牛逼的技術完成了這個牛逼的遊戲。你用React控制遊戲所有的使用SVG建立的元素。你還用CSS動畫讓你的遊戲運動起來。哦對了,你還用了SocketIO來實時更新你的排行榜。還學會了如何接入Auth0的使用者驗證系統。

略略略!你用了很長時間來做這件事並且學到了很多東西。是時候放鬆一下,開始玩你的遊戲了!

相關文章