使用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,最後點選建立。
當你點選了這個按鈕,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
的值也替換掉。
這個檔案發生的改變很簡單,下面是一個改變的列表:
configure
:你使用這個方法來配置了你的auth0-web
。handleAuthCallback
:在componentDidMount
生命週期中,你啟用這個方法來獲取使用者資訊。這個方法會傳送請求從Auth0調取使用者資訊並儲存在localStorage
中。subscribe
:這個方法用來判斷使用者是否已經驗證了。值為布林型別。
好了,你的遊戲現在已經介入了Auth0 的單點登入了。現在執行你的遊戲,你會看見登入按鈕並在點選之後會跳轉到Auth0的登入頁面。
當你成功登陸,Auth0會回撥你遊戲的地址。你之前在程式碼裡面寫了console.log
所以現在我們能看到登陸狀態的任何改變。
建立排行榜元件
現在你已經配置了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;
複製程式碼
別害怕!這裡的程式碼其實超級簡單:
- 你定義了
leaderboardTitle
常量來設定Title的展示。 - 你定義了一個虛線邊框透明的長方體來作為排行榜的容器。
- 你呼叫了
sort
方法來對props.leaderboard
進行排序。之後高分在上低分在下。相同分則會按照名字順序來排。 - 你呼叫了
map
方法對上一步的結果進行包裝,對當前使用者進行標記。你將會用這個標記來對當前使用者進行高亮顯示。 - 你呼叫了
filter
方法,對上一步的結果進行過濾,把前三名以外的玩家移除。不過,如果當前玩家不在前三名,就會在第四個位置顯示而不會被移除。 - 最後,你簡單地遍歷資料進行渲染。
最後你要做的就是製作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
定義的圓形的組合元素,用來展示使用者的圖片。
現在,執行應用檢視效果吧!
使用Socket.IO讓排行榜實時顯示
你已經讓排行榜顯示出來了,下一步做什麼呢?我們可以利用Socket.IO來讓排行榜實時顯示資料。這可能會讓你思考:建立一個實時的服務應該會很困難吧?不,不是的。使用Socket.IO,你可以瞬間完成這個功能。但是,在開始之前,你一定想要保護你的後端服務不被攻擊,對吧?你需要建立一個Auth0 API來代表你的服務。
做起來很容易。開啟Auth0 Dashboard 上面的 API 頁面點選CreateAPI按鈕,然後會蹦出來一個表單讓你填三樣東西:
- API的名字。你需要起一個有代表性的名字防止以後你忘了這個API是做什麼的。
- API驗證地址。先填這個吧:
https://aliens-go-home.digituz.com.br
。 - 選擇一個演算法,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
一樣。下面我們來了解一下這段程式碼:
Express
和Socket.io
:這是一個簡單的Socket.IO
和Express
框架的結合搭建的服務。如果你沒有用過Socket.IO
可以去看看官網基礎教程,非常簡單。jwt
和jwksClient
:在使用Auth0驗證的時候,你的玩家通過別的渠道獲得一個jwt(JSON Web Token)格式的access_token
。因為你用了簡單的RS256
演算法,你需要jwksClient
這個包來獲取正確的公鑰來驗證JWT。如果你感興趣,可以到這裡檢視:auth0.com/docs/jwksjwt.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分別對應兩種情況:
LOGGED_IN
:當使用者登陸了,會觸發這個action去連結你的後臺實時服務。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;
複製程式碼
在上面的程式碼裡你做了這些事:
- 配置了Auth0模組的
audience
屬性。 - 獲取當前使用者的資訊(
Auth0.getProfile()
),建立當前使用者的常量並更新了store
:this.props.loggedIn(...)
。 - 使用玩家的
access_token
連結到後端服務:io('http://localhost:3001', ...)
。 - 監聽你的後臺服務觸發的玩家事件來更新
store
:this.props.leaderboardLoaded(...)
。
你的遊戲還沒有完成,你的玩家現在還不能擊落飛行物。你加入了一些臨時程式碼來模擬new-max-score
事件。首先,你模擬了一個maxScore
是120,這讓玩家拍到了第五。然後5秒鐘(setTimeout(..., 5000)
)之後你有設定maxScore
為222。讓登陸的玩家排到了第二名。
除了這些修改,你還要傳給畫布元件一些新的屬性:currentPlayer
和players
。編輯/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;
複製程式碼
在這個檔案中,你做出瞭如下修改:
- 移除了之前直接硬編碼的
leaderboard
。現在是從後臺服務獲取了。 - 更新了
<Leaderboard/>
的程式碼,現在通過props
獲取了。 - 增強了
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
複製程式碼
完成缺失的部分
已經快完成了,但是還少一些東西:
- 射擊的功能。
- 碰撞檢測。
- 更新生命值資訊和當前分數。
- 更新排行榜。
那麼,在接下來的幾個段落,將會告訴你如何完成剩下的部分。
射擊
你需要新增一個點選事件來讓你的玩家可以發射加農炮彈。點選的時候出發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
作為引數來呼叫shoot
的dispatcher
。然後你把這個新的方法傳給了畫布元件。所以你還要在畫布元件裡新增點選事件來觸發這一系列的流程。
// ... 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
來保證能建立新的炮彈。然後你用這個函式更新了儲存炮彈的陣列。現在你可以執行你的遊戲看一下,已經可以發射炮彈了!
碰撞檢測
現在你的玩家已經可以發射炮彈了,下一步我們來做碰撞檢測。通過這個演算法,你可以把碰到一起的炮彈和飛行物移除。這也將支援你完成下面的任務:漲分。
實現這個功能的一個比較好的戰略是,把炮彈和飛行物都想象成長方形。把他們當做長方形來處理會讓事情變得很容易。在這個遊戲裡你不需要太高的精度。抱著這樣的想法,我們在/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;
複製程式碼
這個檔案做了下面幾件事情:
- 建立了一個陣列叫
objectsDestroyed
來儲存已經被銷燬的物件。 - 遍歷
flyingDiscs
物件,給每個飛行物建立一個矩形檢測區域。既然你是用css來移動他們的,你就應該通過currentLifeTime
來計算他們Y軸上的位置。X軸不會變。 - 遍歷
cannonBalls
物件,給每個炮彈建立矩形檢測區域。 - 呼叫
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
複製程式碼
下面是本次修改的總結:
- 你定義了兩個新的屬性叫做
socket
和currentPlayer
。你可以在不同的方法裡使用它們。 - 你一出了為了模擬
new-max-score
建立的分數們。 - 你遍歷了從
Socket.IO
獲得的玩家列表來渲染元件。 - 你定義了
componentWillReceiveProps
生命週期函式來檢查玩家是否達到了最高分,如果達到了,就更新最高分的記錄。
好了!完成了!試試你的遊戲吧!
node ./server/index &
npm start
複製程式碼
這時你建立兩個不同的賬戶,就會有一個真實的排行榜了。
總結
這一系列下來,你使用牛逼的技術完成了這個牛逼的遊戲。你用React控制遊戲所有的使用SVG建立的元素。你還用CSS動畫讓你的遊戲運動起來。哦對了,你還用了SocketIO來實時更新你的排行榜。還學會了如何接入Auth0的使用者驗證系統。
略略略!你用了很長時間來做這件事並且學到了很多東西。是時候放鬆一下,開始玩你的遊戲了!