- 原文地址:Developing Games with React, Redux, and SVG - Part 3
- 原文做者:Bruno Krebs
- 譯文出自:掘金翻譯計劃
- 本文永久連接:github.com/xitu/gold-m…
- 譯者:xueshuai
- 校對者:
提示: 在這個系列中,你將學習如何使用 React 和 Redux 控制一堆 SVG 元素來建立一個遊戲。這個系列所須要的知識一樣也可使你建立使用 React 和 Redux 的其餘類型的動畫,而不僅是遊戲。你可以在下面的 GitHub 倉庫中找到文章中開發的最終代碼:Aliens Go Home - 第 3 部分html
在這個教程中你開發的遊戲叫作 Aliens, Go Home! 這個遊戲的想法很簡單,你有一門大炮,你將必須殺掉嘗試入侵地球的飛行物體。要殺掉這些飛行的物體,你將必須標示和點擊 SVG canvas 來使你的大炮發射。前端
若是你有些疑惑,你能夠發現完成了的遊戲並在這裏運行它。可是不要玩的太多,你還有工做必須作。node
「我正在用 React,Redux 和 SVG元素react
建立一個遊戲。」android
在 這個系列的第一部分,你已經使用 create-react-app
來啓動你的 React 應用,你已經安裝和配置了 Redux 來管理遊戲的狀態。以後,在建立遊戲的元素時,例如 Sky
, Ground
, CannonBase
和 CannonPipe
, 你已經學習瞭如何在 React 組件中使用 SVG。最終,你經過使用事件監聽方法給你的大炮添加動畫效果和一個 JavaScript interval 來觸發 Redux 的 action 更新 CannonBase
的角度。ios
這些爲你提供了理解如何使用React,Redux和SVG來建立你的遊戲(和其餘動畫)的方法。git
在 第二部分,你已經建立了遊戲中其餘的必須元素(例如 Heart
, FlyingObject
和 CannonBall
),使你的玩家可以開始遊戲,並使用 CSS 動畫讓飛行物體飛起來(這就是他們應該作的事,對麼?)。github
就算是咱們有了這些很是好的特性,可是他們尚未構成一個完整的遊戲。你仍然須要使你的大炮發射炮彈,並完成一個算法來檢測飛行物體和炮彈的碰撞。除此以外,你必須在你的玩家殺死外星人的時候,增長 CurrentScore
。web
殺死外星人和看到當前分數的增加很酷,可是你可能會使這個遊戲更有吸引力。。這就是爲何你要在你的遊戲中增長一個排行榜特性。這將會使你的玩家花費更多的時間來達到排行榜的高位。算法
有了這些特性,你能夠說你有了一個完整的遊戲。因此,爲了節約時間,是時候關注他們了。
提示: 若是(不管是什麼緣由)你沒有 前面兩部分 建立的代碼,你能夠從 這個 GitHub 倉庫 克隆他們。克隆以後,你可以繼續跟隨接下來板塊中的指示。
第一件你要作的使你的遊戲看起來更像一個真正的遊戲的事情就是實現排行榜特性。這個特性將使玩家可以登錄,因此你的遊戲可以跟蹤他們的最高分數和他們的排名。
要使 Auth0 管理你的玩家的身份,你必須有一個 Auth0 帳戶。若是你尚未,你能夠 在這裏 註冊一個免費 Auth0 帳戶。
註冊完你的帳戶以後,你只須要建立一個 Auth0 應用 來表明你的遊戲。要作這個,前往 Auth0 的儀表盤中的 Application 頁面 ,而後點擊 Create Application 按鈕。儀表盤將會給你展現一個表單,你必須輸入你的應用的 name 和 type 。你能輸入 Aliens, Go Home! 做爲名字,並選擇 Single Page Web Application 做爲類型(畢竟你的遊戲是基於 React 的 SPA)。而後,你能夠點擊 Create。
當你點擊這個按鈕,儀表盤將會把你重定向到你的新應用的 Quick Start 標籤頁。正如你將在這篇文章中學習如何整合 React 和 Auth0,你不須要使用這個標籤頁。取而代之的,你將須要使用 Settings 標籤頁,因此咱們前往這個頁面。
這裏有三件事你須要在這個標籤頁作。第一件是添加 http://localhost:3000
到名爲 Allowed Callback URLs 的字段。正如儀表盤解釋的, 在你的玩家認證以後, Auth0 只會回跳到這個字段 URLs 中的一個 。因此,若是你想在網絡上發佈你的遊戲,不要忘了在那裏一樣加入你的外網 URL (例如 http://aliens-go-home.digituz.com.br
)。
在這個字段輸入你全部的 URLs 以後,點擊 Save 按鈕或者按下 ctrl
+ s
(若是你是用的是 MacBook,你須要按下 command
+ s
)。
你須要作的最後兩件事是複製 Domain 和 Client ID 字段的值。無論怎樣,在你使用這些值以前,你須要敲一些代碼。
對於初學者,你將須要在你遊戲的根目錄下輸入如下命令來安裝 auth0-web
包:
npm i auth0-web
複製代碼
正如你將看到的,這個包將有助於整合 Auth0 和 SPAs。
下一步是在你的遊戲中增長一個登錄按鈕,使你的玩家可以經過 Auth0\ 認證。完成這個,要在 ./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
組件的時候定義它的操做。因此,打開 Canvas.jsx
文件,參照下面更新它:
// ... other import statements
import Login from './Login';
import { signIn } from 'auth0-web';
const Canvas = (props) => {
// ... const definitions
return (
<svg ...>
// ... other elements
{ ! props.gameState.started &&
<g>
// ... StartGame and Title components
<Login authenticate={signIn} />
</g>
}
// ... flyingObjects.map
</svg>
);
};
// ... propTypes definition and export statement
複製代碼
正如你看見的,在這個新版本里,你已經引入了 Login
組件和 auth0-web
包裏的 signIn
方法。而後,你已經把你的新組件加入到了代碼塊中,只在玩家沒有開始遊戲的時候出現。一樣的,你已經預料到,當點擊的時候,登錄按鈕必定會觸發 signIn
方法。
當這些變化發生的時候,最後一件你必須作的事是在你的 Auth0 應用的屬性中配置 auth0-web
。要作這件事,須要打開 App.js
文件並按照下面更新它:
// ... other import statements
import * as Auth0 from 'auth0-web';
Auth0.configure({
domain: 'YOUR_AUTH0_DOMAIN',
clientID: 'YOUR_AUTH0_CLIENT_ID',
redirectUri: 'http://localhost:3000/',
responseType: 'token id_token',
scope: 'openid profile manage:points',
});
class App extends Component {
// ... constructor definition
componentDidMount() {
const self = this;
Auth0.handleAuthCallback();
Auth0.subscribe((auth) => {
console.log(auth);
});
// ... setInterval and onresize
}
// ... trackMouse and render functions
}
// ... propTypes definition and export statement
複製代碼
提示: 你必須使用從你的 Auth0 應用中複製的 Domain 和 Client ID 字段的值來替換
YOUR_AUTH0_DOMAIN
和YOUR_AUTH0_CLIENT_ID
。除此以外,當你在網絡上發佈你的遊戲的時候,你一樣須要替換redirectUri
的值。
這個文件裏的加強的點十分簡單。這個列表總結了他們:
configure
:你使用這個函數,協同你的 Auth0 應用的屬性,來配置 auth0-web
包。handleAuthCallback
:你在 componentDidMount
生命週期的鉤子函數 觸發這個方法,來檢測用戶是不是通過 Auth0 認證的。 這個方法只是嘗試從 URL 抓取 tokens,而且若是成功,抓取用戶的文檔並把全部的信息存儲到localstorage
。subscribe
:你使用這個方法來來記錄玩家是不是通過認證的(true
認證過,false
表明其餘)。就是這樣,你的遊戲已經 使用 Auth0 做爲它的身份管理服務。若是你如今啓動你的應用(npm start
)而且在你的瀏覽器中瀏覽 (http://localhost:3000
),你講看到登錄按鈕。點擊它,它會把你重定向到 Auth0 登錄頁面,在這裏你能夠登錄。
當你完成了流程中的註冊,Ahth0 會再一次把你重定向到你的遊戲,handleAuthCallback
方法將會抓去你的 tokens。而後,正如你已經告訴你的應用 console.log
全部的認證狀態的變化,你將可以看到它在你的瀏覽器控制檯打印了 true
。
「使用 Auth0 來保護你的遊戲是簡單和痛苦小的。」
如今你已經配置了 Auth0 做爲你的身份管理系統,你將須要建立展現排行榜和當前玩家最大分數的組件。爲這個,你將建立兩個組件:Leaderboard
和 Rank
。你將須要將這個特性拆分紅兩個組件,由於正如你所看到的,友好的展現玩家的數據(好比最大分數,姓名,位置和圖片)並非簡單的事。其實也並不困難,可是你須要編寫一些好的代碼。因此,把全部的東西加到一個組件之中會看起來很笨拙。
正如你的遊戲尚未任何玩家,第一件事你須要作的就是定義一些 mock 數據來填充排行榜。作這件事最好的地方就是在 Canvas
組件中。一樣,由於你正要去更新你的 canvas,你可以繼續深刻,使用 Leaderboard
替換 Login
組件(你一下子將在 Leaderboard
中加入 Login
):
// ... 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
複製代碼
在這個文件的新版本中,你定義一個存儲假玩家的叫作 leaderboard
的數組常量。這些玩家有如下屬性:id
,maxScore
,name
和 picture
。而後,在 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
來設置你的排行榜標題是什麼樣的。dashedRectangle
來設置做爲你的排行榜容器的 rect
元素的樣式。props.leaderboard
變量的 sort
方法來排序。以後,你的排行榜就會使最高分在上面,最低分在下面。一樣,若是有兩個玩家打平手,你根據姓名將他們排序。sort
方法)的結果上調用 map
方法,使用他們的 rank
和 具備 currentPlayer
的標誌來補充玩家信息。你將使用這個標誌來高亮當前玩家出現的行。map
方法)的結果上調用 filter
方法來刪除每個不在前三名玩家的人。事實上,若是當前玩家不屬於這個篩選組,你要使當前玩家保留在最終的數組裏。props.currentPlayer && leaderboard.map
)或者正在展現 Login
按鈕,你遍歷過濾過得數組來展現 Rank
元素。最後一件你須要作的事就是建立 Rank
React component。要完成這個,建立一個名爲 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
元素 和一個在 defs
元素中的 rect
元素來建立一個圓的肖像。
有了這些新文件,你可以前往你的應用(http://localhost:3000/
)來看看你的新排行榜特性。
帥氣,你已經使用 Auth0 做爲你的身份管理服務,而且你也建立了須要展現排行榜的組件。以後,你須要作什麼?對了,你須要一個能出發實時事件的後端來更新排行榜。
這可能使你想到:開發一個實時後端服務器困難麼?不,不困難。使用 Socket.IO,你能夠在很短的時間實現這個特性。無論怎樣,在深刻以前,你可能想要好糊這個後端服務,對不對?要作這個,你須要建立一個 Auth0 API 來表明你的服務。
這樣作很簡單。前往 你的 Auth0 儀表盤的 APIs 頁面 而且點擊 Create API 按鈕,Auth0 會想你展現一個有三個信息須要填的表單:
https://aliens-go-home.digituz.com.br
。在你填完這個表單後,點擊 Create 按鈕。會將你重定向到你的新 API 中叫作 Quick Start 的標籤頁。在那裏,點擊 Scopes 標籤而且添加叫作 manage:points
的新做用域,他有如下的描述:「讀和寫最大的分數」。在 Auth0 APIs 上定義做用域是很好的實踐
添加完這個做用域以後,你可以繼續編程。來完成你的實時排行榜服務,按照下面的作:
# 在項目根目錄建立一個服務目錄
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');
});
複製代碼
在學習這部分代碼作什麼以前,使用你的 Auth0 域(和你添加到 App.js
文件是同樣那個)替換 YOUR_AUTH0_DOMAIN
。你能夠在 jwksUri
屬性值中找到這個佔位符。
如今,爲了理解這個事情是怎麼工做的,查看這個列表:
express
和 socket.io
:這只是一個經過 Socket.IO 增強的 Express 服務器來使它具有實時的特性。若是你之前沒有用過 Socket.IO,查看他們的 Get Started 教程。它真的很簡單。jwt
和 jwksClient
:當 Auth0 認證的時候,你的玩家(在其餘事情以外)會在 JWT (JSON Web Token) 表單中獲得一個 access_token
。由於你使用 RS256 簽名算法,你須要使用 jwksClient
包來獲取正確的公鑰來認證 JWTs。你收到的 JWTs 中包含一個 kid
屬性(Key ID),你可使用這個屬性獲得正確的公鑰(若是你感到困惑,你能夠在這兒瞭解更多地 JWKS)。jwt.verify
:在找到正確的鑰匙以後,你可使用這個方法來解碼和認證 JWTs。若是他們都很好,你就給請求的人發送 players
列表。若是他們沒有通過認證,你 disconnect
這個 socket
(用戶)。on('new-max-score', ...)
:最後,你在 new-max-score
事件上附加 newMaxScoreHandler
方法。所以,不管何時你須要更新一個用戶的最高分,你會須要在你的 React 應用中觸發這個事件。剩餘的代碼很是直觀。所以,你能關注在你的遊戲中集成這個服務。
在建立你的實時後端服務以後,是時候將它集成到你的 React 遊戲中了。使用 React 和 Socket.IO 最好的方式是安裝 socket.io-client
包。你能夠在你的 React 應用根目錄下輸入如下命令來安裝它:
npm i socket.io-client
複製代碼
而後,在那以後,不管何時玩家認證,你將使你的遊戲鏈接你的服務(你不須要給沒有認證的玩家顯示排行榜)。由於你使用 Redux 來保存遊戲的狀態,你須要兩個 actions 來保持你的 Redux 存儲最新。所以,打開 ./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 ...
複製代碼
這個新版本定義在兩種狀況下會被觸發的 actions:
LOGGED_IN
:當一個玩家登錄,你使用這個 action 鏈接你的 React 遊戲到實時服務。LEADERBOARD_LOADED
:當實時服務發送玩家列表,你使用這個 action 用這些玩家來更新 Redux 存儲。要使你的 Redux 存儲迴應這些 actions,打開 ./src/reducers/index.js
文件而且按照下面來更新它:
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;
複製代碼
如今,不管你的遊戲何時觸發 LEADERBOARD_LOADED
action,你會使用新的玩家數組列表來更新你的 Redux 存儲。除此以外,不管何時一個玩家登錄(LOGGED_IN
),你將在你的存儲中更新 currentPlayer
。
而後,爲了是你的遊戲使用這些新的 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()
)來建立 currentPlayer
常量,而且更新了 Redux 存儲(this.props.loggedIn(...)
);access_token
鏈接你的實時服務(io('http://localhost:3001', ...)
);this.props.leaderboardLoaded(...)
);而後,你的遊戲尚未完成,你的玩家還不能殺死外星人,你加入一些臨時代碼模擬 new-max-score
事件。第一,你出發一個新的 120
分的 maxScore
,把登錄的玩家放在第五的位置。而後,五秒鐘(setTimeout(..., 5000)
)以後,你出發一個新的 222
分的 maxScore
,把登錄的玩家放在第二的位置。
除了這些變化,你向你的 Canvas
傳入兩個新的屬性: 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.currentPlayer
and props.players
。propTypes
的定義使 Canvas
組件可以使用 currentPlayer
和 players
的值。好了!你已經整合了你的 React 遊戲排行榜和 Socket.IO 實時服務。要測試全部的事務,執行如下的命令:
# 進入實時服務的目錄
cd server
# 在後臺運行這個命令
node index.js &
# 回到你的遊戲
cd ..
# 啓動 React 開發服務
npm start
複製代碼
而後,在瀏覽器中打開你的遊戲(http://localhost:3000
)。這樣,在登錄以後,你就能看到你出如今了第五的位置,5秒鐘以後,你就會跳到第二的位置。
如今,你已經差很少完成了你的遊戲的全部東西。你已經建立了遊戲須要的 React 元素,你已經添加了絕大部分的動畫效果,你已經實現了排行榜特性。這個難題的遺失的部分是:
因此,在接下來的部分,你將關注實現這些部分來完成你的遊戲。
要使你的玩家射擊大炮炮彈,你將在你的 Canvas
添加一個 onClick
時間偵聽器。而後,當點擊的時候,你的 canvas 會觸發 Redux 的 action 添加一個炮彈到 Redux store(實際上就是你的遊戲的 state)。炮彈的移動將被 moveObjects
reducer 處理。
要開始實現這個特性,你能夠從建立 Redux action 開始。要作這個,打開 ./src/actions/index.js
文件,加入如下代碼:
// ... other string constants
export const SHOOT = 'SHOOT';
// ... other function constants
export const shoot = (mousePosition) => ({
type: SHOOT,
mousePosition,
});
複製代碼
而後,你可以準備 reducer(./src/reducers/index.js
)來處理這個 action:
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
}
}
複製代碼
正如你看到的,你的 reducer 的新版本在接收到 SHOOT
action 時,使用 shoot
方法。你仍然須要定義這個方法。因此,在和 reducer 一樣的目錄下建立一個名爲 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 的 state。
在定義這個 action 和 reducer 處理它以後,你將更新 Game
容器給 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
文件來使用你的 dispatch wrapper:
// ... 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。而後,你傳遞把這個新的方法傳遞到 Canvas
組件。因此,你仍然須要增強這個組件,將這個方法附加到 svg
元素的 onClick
事件監聽器而且使它渲染加農炮彈:
// ... 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
複製代碼
提示: 在
CannonPipe
以前 添加cannonBalls.map
很重要,不然炮彈將和大炮自身重疊。
這些改變足夠是你的遊戲在炮彈的初始位置添加炮彈了(x: 0
, y: 0
)而且 他們的彈道(angle
)已經定義好。如今的問題是這些對象是沒有動畫的(其實就是他們不會動)。
要使他們動,你將須要在 ./src/utils/formulas.js
文件中添加兩個函數:
// ... other functions
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
。這就是,你刪除了 Y-axis 座標小於 -800
,或者向左邊移動太多的(小於 -500
),或者向右邊移動太多的(大於 500
)。
最後,要使用這個方法,你將須要將 ./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
reducer 來使用新的 moveBalls
函數。而後,你使用這個函數的結果來給 gameState
的 cannonBalls
屬性定義一個新數組。
如今,完成了這些更改以後,你的玩家可以發射炮彈了。你能夠在一個瀏覽器中經過測試你的遊戲來查看這一點。
如今你的遊戲支持發射炮彈而且這裏有飛行的物體入侵地球,這是一個好的時機添加一個檢測碰撞的算法。有了這個算法,你能夠刪除相碰撞的炮彈和飛行物體。這也使你可以繼續接下來的特性: 增長當前的分數。
一個好的實現這個檢測碰撞算法的策略是把炮彈和飛行物體想象成爲矩形。儘管這個策略不如按照物體真實形狀實現的算法準確,可是把它們做爲矩形處理會使每件事情變得簡單。除此以外,對於這個遊戲,你不須要很精確,由於,幸運的是,你不須要這個算法殺死真的外星人。
在腦殼中有這個想法以後,添加接下來的方法到 ./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
數組(使用 forEach
方法)建立矩形來表明飛行物。提示,由於你使用 CSS 動畫來使物體移動,你須要基於 currentLifeTime
的 Y-axis 計算他們位置。cannonBalls
數組(使用 forEach
方法)建立矩形來表明炮彈。checkCollision
方法,來決定這兩個矩形是否必須被摧毀。而後,若是他們必須被摧毀,他們被添加到 objectsDestroyed
數組,由這個方法返回。Lastly, you will need to update the moveObjects.js
file to use this function as follows: 最後,你須要更新 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
函數的結果從 cannonBalls
和 flyingObjects
數組中移除對象。
如今,當炮彈和飛行物體重疊,新版本的 moveObjects
reducer 把它們從 gameState
刪除。你能夠在瀏覽器中看到這個 action。
不管何時飛行的物體入侵地球,你必須減小玩家持有的命的數量。因此,當玩家沒有更多地生命值的時候,你必須結束遊戲。要實現這些特性,你只須要更新兩個文件。第一個文件是 ./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
數組和其在 state
中的初始長度來決定玩家是否失去生命。這個策略有效是由於你把這些代碼添加在了彈出飛行物體以後而且在刪除碰撞物體以前。這些飛行物體在遊戲中保持 4 秒鐘((now - object.createdAt) < 4000
)。因此,若是這些數組的長度發生了變化,就意味着飛行物體入侵了地球。
如今,給玩家展現他們的生命數,你須要更新 Canvas
組件。因此,打開 ./src/components/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
複製代碼
有了這些更改,你的遊戲幾乎完成了。玩家已經可以發射和殺死飛行物體,而且若是太多的它們進攻地球,遊戲結束。如今,爲了完成這部分,你須要更新玩家當前的分數,這樣他們才能比較誰殺了更多地外星人。
作這個來增強你的遊戲很簡單。你只須要按如下來更新 ./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;
複製代碼
而後,在 ./src/components.Canvas.jsx
文件,你須要用這個來替換 CurrentScore
組件(硬編碼值爲 15):
<CurrentScore score={props.gameState.kills} />
複製代碼
「我使用 React、Redux、SVG 和 CSS 動畫建立一個遊戲。」
好消息!更新排行榜是你說你使用 React、Redux、SVG 和 CSS 動畫完成了一個遊戲所須要作的最後一件事。一樣的,正如你看到的,這裏的工做很快而且沒有痛苦。
第一,你須要更新 ./server/index.js
文件來重置 players
數組。你不但願你發佈的遊戲裏是假用戶和假結果。因此,打開這個文件而且刪除全部的假玩家/結果。最後,你會有像下面這樣定義的常量:
const players = [];
複製代碼
而後,你須要重構 App
組件。因此,打開 ./src/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
事件的假的最高分。players
數組(你從 Socket.IO 後臺接收到的)來設置玩家正確的最高分。就這樣,若是他們再一次回來啊,他們仍然會有 maxScore
記錄componentWillReceiveProps
生命週期來檢查玩家是否打到了一個新的 maxScore
。若是是,你的遊戲觸發一個 new-max-score
事件去更新排行榜這就是了!你的遊戲已經準備好了第一次。要看全部的行爲,用下面的代碼運行 Socket.IO 後臺和你的 React 應用:
# 在後臺運行後端服務
node ./server/index &
# 運行 React 應用
npm start
複製代碼
而後,運行瀏覽器,使用不一樣得 email 地址認證,而且殺一些外星人。你能夠看到,當遊戲結束的時候,排行榜將會在兩個瀏覽器更新。
在這個系列中,你使用了不少驚人的技術來建立一個好遊戲。你使用了 React 來定義和控制遊戲元素,你使用了 SVG(代替 HTML)來渲染這些元素,你使用了 Redux 來控制遊戲的狀態,而且你使用了 CSS 動畫使外星人在屏幕上運動。哦,除此以外,你甚至使用了一點 Socket.IO 使你的排行榜是實時的,並使用 Auth0 做爲你遊戲的身份管理系統。
唉!你走了很長的路,你在這三篇文章中學了不少。多是時候休息一下,玩會兒你的遊戲了。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。