使用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的用户验证系统。
略略略!你用了很长时间来做这件事并且学到了很多东西。是时候放松一下,开始玩你的游戏了!