使用React、 Redux 和 SVG 开发游戏(二)
本文翻译自:Developing Games with React, Redux, and SVG - Part 2
转载英文原作请注明原作者与出处。转载本译本请注明译者与译者博客
关于安装npm包
译者:如果你安装npm包过程中出现错误,请尝试更新npm自身至最新的版本之后再次安装。
// 有些机器可能会有权限问题,所以这里sudo了
sudo npm install npm@latest -g
前情回顾
在上一部分中,你使用create-react-app
初始化了项目,安装并配置了redux
来管理游戏的状态。在那之后,你学会了如何在React
中制作SVG
组件。我们做了多个组件比如天空组件、陆地组件还有加农炮体和炮管。最后我们通过使用计时器,监听事件然后触发redux
action来更新炮管的角度让加农炮有了瞄准的能力。
这些东西为你铺平了接下来的道路。
注意:不管什么原因,如果你没有第一部分的代码,你可以到这里clone
一份。clone
了之后,你就可以开始接下来的教程了。
创建更多的SVG React 组件
接下来的部分会告诉你如何创建剩下的游戏元素。尽管它们看起来好像很长,其实都跟前面很相似也很简单。甚至,你可能几分钟就完成了。
这段结束之后,你会发现本系列教程第二部分最好玩的一些话题——让飞行的物体随机出现并用CSS
来移动它们。
创建炮弹组件
接下来你要制作的组件是炮弹组件,起名叫cannonBall
把。注意,现在开始你做的东西可能暂时都是静止的,别担心,我们制作完所有组件之后会让他们动起来,会让你的炮弹打飞碟的!
创建一个CannonBall.jsx
组件,放到src/components
里去。代码如下:
import React from 'react';
import PropTypes from 'prop-types';
const CannonBall = (props) => {
const ballStyle = {
fill: '#777',
stroke: '#444',
strokeWidth: '2px',
};
return (
<ellipse
style={ballStyle}
cx={props.position.x}
cy={props.position.y}
rx="16"
ry="16"
/>
);
};
CannonBall.propTypes = {
position: PropTypes.shape({
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired
}).isRequired,
};
export default CannonBall;
如你所见,你必须传一个对象给你的CannonBall
组件,才能在你的画布上显示炮弹。传入的这个对象需要包含x
和y
两个数字类型的属性。如果你对prop-types
不熟悉,这可能是你第一次使用propTypes.shape
。幸运的是,这个功能顾名思义,规定形状的,还是比较好理解的。
在你创建完成这个组件之后,以一定想让他展示在你的画布上。你只需要在你的Canvas
组件里把这个组件加进去就可以了,别忘记import
。
<CannonBall position={{x: 0, y: -100}}/>
不要忘记一点,顺序很重要。本系列第一部分说过,SVG
没有层级,完全依赖于渲染的顺序来决定覆盖。所以把炮弹放在<CannonBase />
后面吧。
如果你不知道到底该按什么顺序放置。干脆随便放一个差不多的地方然后npm start
到页面里去看,慢慢调整吧~
创建实时分数显示组件
下面你要做的是记录分数的组件:CurrentScore
。跟字面意思一样,你需要使用这个组件来记录当前玩家的得分。就是,没击落一个飞碟,就加一分。
在创建这个组件之前,可能需要添加一些你可能想要一些好看的字体。事实上,你可能想在所有显示字的地方都用自定义的字体,让游戏看起来没有那么的死板。你可以去任何地方找你喜欢的字体,不过你如果懒得找,你可以直接在src/index.css
第一行添加如下代码:
@import url('https://fonts.googleapis.com/css?family=Joti+One');
译者:这里是Google的api,如果你不能翻墙。下面这是api响应的内容,里面有字体的链接,你可以把字体下载下来之后自己写。如果想不到办法,微软雅黑吧。
/* latin-ext */
@font-face {
font-family: 'Joti One';
font-style: normal;
font-weight: 400;
src: local('Joti One'), local('JotiOne-Regular'), url(https://fonts.gstatic.com/s/jotione/v5/Z9XVDmdJQAmWm9TwabTX6OymlLGDzCs.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Joti One';
font-style: normal;
font-weight: 400;
src: local('Joti One'), local('JotiOne-Regular'), url(https://fonts.gstatic.com/s/jotione/v5/Z9XVDmdJQAmWm9TwabTZ6OymlLGD.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
这将让你的项目加载谷歌的the Joti One font
引入了字体之后你可以在src/components
中创建CurrentScore.jsx
文件。代码如下:
import React from 'react';
import PropTypes from 'prop-types';
const CurrentScore = (props) => {
const scoreStyle = {
fontFamily: '"Joti One", cursive',
fontSize: 80,
fill: '#d6d33e',
};
return (
<g filter="url(#shadow)"> //#shadow后面会说
<text style={scoreStyle} x="300" y="80">
{props.score}
</text>
</g>
);
};
CurrentScore.propTypes = {
score: PropTypes.number.isRequired,
};
export default CurrentScore;
注意:如果你使用了其他的字体那么你要把fontFamily
的值改成你用的字体。并且其他所有地方都要做出同样的改变。
如你所见,CurrentScore
组件只需要一个属性score
。因为你的游戏现在还是死的,不能计算分数,为了让分数显示出来看看效果,我们需要先硬编码一个分数。在Canvas
组件中SVG
最后部分添加<CurrentScore score={15} />
。同样,别忘记import
。
如果你现在去页面,你是看不到分数的。因为前面用了一个filter
叫做shadow
。尽管这个filter
并不是必须的,有了它可以让你的游戏看起来更好看一点儿。并且,添加一个SVG滤镜并不难。我们只需在你的SVG
标签顶部定义一下这个滤镜就可以了。Canvas
组件最后看起来是这样的:
import React from 'react';
import PropTypes from 'prop-types';
import Sky from './Sky';
import Ground from './Ground';
import CannonBase from './CannonBase';
import CannonPipe from './CannonPipe';
import CannonBall from './CannonBall';
import CurrentScore from './CurrentScore';
const Canvas = (props) => {
const viewBox = [window.innerWidth / -2, 100 - window.innerHeight, window.innerWidth, window.innerHeight];
return (
<svg
id="aliens-go-home-canvas"
preserveAspectRatio="xMaxYMax none" // 可以删掉
onMouseMove={props.trackMouse}
viewBox={viewBox}
>
<defs>
<filter id="shadow">
<feDropShadow dx="1" dy="1" stdDeviation="2" />
</filter>
</defs>
<Sky />
<Ground />
<CannonPipe rotation={props.angle} />
<CannonBase />
<CannonBall position={{x: 0, y: -100}}/>
<CurrentScore score={15} />
</svg>
);
};
Canvas.propTypes = {
angle: PropTypes.number.isRequired,
trackMouse: PropTypes.func.isRequired,
};
export default Canvas;
然后你的游戏现在是这个样子了。
还特么可以把?huh?(译者:浓浓的小猪佩奇风)创建FO组件
来创建一个代表飞行物的组件怎么样?飞行物可不是简单的圆儿或者方块儿什么的基本图形。它是由两部分组成的:顶帽和主体。所以,像之前的加农炮一样,我们也创建两部分组件:FlyingObjectBase.jsx
和FlyingObjectTop.jsx
。
这两个组件其中一个将会使用贝塞尔曲线来定义它的形状,另外一个只需要使用一个ellipse
即可。
你可以先来创建FlyingObjectBase.jsx
。在src/components/
里创建FlyingObjectBase.jsx
:
import React from 'react';
import PropTypes from 'prop-types';
const FlyingObjectBase = (props) => {
const style = {
fill: '#979797',
stroke: '#5c5c5c',
};
return (
<ellipse
cx={props.position.x}
cy={props.position.y}
rx="40"
ry="10"
style={style}
/>
);
};
FlyingObjectBase.propTypes = {
position: PropTypes.shape({
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired
}).isRequired,
};
export default FlyingObjectBase;
然后在同一目录创建FlyingObjectTop.jsx
。
import React from 'react';
import PropTypes from 'prop-types';
import { pathFromBezierCurve } from '../utils/formulas';
const FlyingObjectTop = (props) => {
const style = {
fill: '#b6b6b6',
stroke: '#7d7d7d',
};
const baseWith = 40;
const halfBase = 20;
const height = 25;
const cubicBezierCurve = {
initialAxis: {
x: props.position.x - halfBase,
y: props.position.y,
},
initialControlPoint: {
x: 10,
y: -height,
},
endingControlPoint: {
x: 30,
y: -height,
},
endingAxis: {
x: baseWith,
y: 0,
},
};
return (
<path
style={style}
d={pathFromBezierCurve(cubicBezierCurve)}
/>
);
};
FlyingObjectTop.propTypes = {
position: PropTypes.shape({
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired
}).isRequired,
};
export default FlyingObjectTop;
如果你不知道怎么使用贝塞尔曲线,请参照上一篇内容。
现在有的组件已经足够显示飞行物了,但是,你想要让他们随机出现在游戏区域内,先把飞行物的零件组件组装起来会更容易些。在同一目录创建FlyingObject.jsx
:
import React from 'react';
import PropTypes from 'prop-types';
import FlyingObjectBase from './FlyingObjectBase';
import FlyingObjectTop from './FlyingObjectTop';
const FlyingObject = props => (
<g>
<FlyingObjectBase position={props.position} />
<FlyingObjectTop position={props.position} />
</g>
);
FlyingObject.propTypes = {
position: PropTypes.shape({
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired
}).isRequired,
};
export default FlyingObject;
现在,可以将你的飞行物添加到你的游戏中去了。编辑你的画布:
<FlyingObject position={{x: -150, y: -300}}/>
<FlyingObject position={{x: 150, y: -300}}/>
别忘记import
。
现在你的游戏长这样了:
创建心❤️组件
下一个组件是用来表示当前玩家剩余机会数的,用小红心来表示。所以,这个组件叫Heart
。那么,在src/components
创建一个Heart.jsx
吧:
import React from 'react';
import PropTypes from 'prop-types';
import { pathFromBezierCurve } from '../utils/formulas';
const Heart = (props) => {
const heartStyle = {
fill: '#da0d15',
stroke: '#a51708',
strokeWidth: '2px',
};
const leftSide = {
initialAxis: {
x: props.position.x,
y: props.position.y,
},
initialControlPoint: {
x: -20,
y: -20,
},
endingControlPoint: {
x: -40,
y: 10,
},
endingAxis: {
x: 0,
y: 40,
},
};
const rightSide = {
initialAxis: {
x: props.position.x,
y: props.position.y,
},
initialControlPoint: {
x: 20,
y: -20,
},
endingControlPoint: {
x: 40,
y: 10,
},
endingAxis: {
x: 0,
y: 40,
},
};
return (
<g filter="url(#shadow)">
<path
style={heartStyle}
d={pathFromBezierCurve(leftSide)}
/>
<path
style={heartStyle}
d={pathFromBezierCurve(rightSide)}
/>
</g>
);
};
Heart.propTypes = {
position: PropTypes.shape({
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired
}).isRequired,
};
export default Heart;
如你所见,创建一个心形的SVG
你需要两条贝塞尔曲线,左右各一条。你还需要添加一个position
属性给这个组件,因为用户有很多条命,所以每个Heart
的实例都需要不同的位置。
现在,你可以先直接添加一个固定位置的❤️到你的画布中:
<Heart position={{x: -300, y: 35}} />
他必须是SVG
中的最后一个元素防止别人盖住它。对了,还是那句话,别忘记import
。
创建开始按钮
每个游戏都需要开始按钮。所以给你的游戏添加一个启动器StartGame
吧。还是在组件文件夹里创建StartGame.jsx
并键入下面的代码:
import React from 'react';
import PropTypes from 'prop-types';
import { gameWidth } from '../utils/constants';
const StartGame = (props) => {
const button = {
x: gameWidth / -2, // half width
y: -280, // minus means up (above 0)
width: gameWidth,
height: 200,
rx: 10, // border radius
ry: 10, // border radius
style: {
fill: 'transparent',
cursor: 'pointer',
},
onClick: props.onClick,
};
const text = {
textAnchor: 'middle', // center
x: 0, // center relative to X axis
y: -150, // 150 up
style: {
fontFamily: '"Joti One", cursive',
fontSize: 60,
fill: '#e3e3e3',
cursor: 'pointer',
},
onClick: props.onClick,
};
return (
<g filter="url(#shadow)">
<rect {...button} />
<text {...text}>
Tap To Start!
</text>
</g>
);
};
StartGame.propTypes = {
onClick: PropTypes.func.isRequired,
};
export default StartGame;
不同于❤️组件,这个组件只有一个所以也不需要什么位置属性传入了,直接在组件内部根据画布大小来计算一个位置就可以了。另外,这个组件跟你之前定义的组件还有两个不同点:
- 这个组件需要一个函数参数
onClice
。用来监听玩家的鼠标点击事件并处罚react
的action
来让你的应用开始一场新的游戏。 - 这个组件用了一个你还没有定义的常量叫做
gameWidth
。这个常量规定了游戏的可用区域。这个区域以外的画面,除了用来填充屏幕,没别的用处。
打开src/utils/constants.js
文件来定义这个常量。
export const gameWidth = 800;
之后你可以在画布中SVG
标签的最后面添加这个组件,以显示开始游戏的按钮。
<StartGame onClick={() => console.log('Aliens, Go Home!')} />
我墨迹死你:别忘记import
。
创建标题组件
本系列第二部分,你需要创建的最后一个组件是标题组件。你的游戏已经有名字了: Aliens, Go Home!。创建标题非常简单,照猫画虎在组件文件夹中创建一个Title.jsx
:
import React from 'react';
import { pathFromBezierCurve } from '../utils/formulas';
const Title = () => {
const textStyle = {
fontFamily: '"Joti One", cursive',
fontSize: 120,
fill: '#cbca62',
};
const aliensLineCurve = {
initialAxis: {
x: -190,
y: -950,
},
initialControlPoint: {
x: 95,
y: -50,
},
endingControlPoint: {
x: 285,
y: -50,
},
endingAxis: {
x: 380,
y: 0,
},
};
const goHomeLineCurve = {
...aliensLineCurve,
initialAxis: {
x: -250,
y: -780,
},
initialControlPoint: {
x: 125,
y: -90,
},
endingControlPoint: {
x: 375,
y: -90,
},
endingAxis: {
x: 500,
y: 0,
},
};
return (
<g filter="url(#shadow)">
<defs>
<path
id="AliensPath"
d={pathFromBezierCurve(aliensLineCurve)}
/>
<path
id="GoHomePath"
d={pathFromBezierCurve(goHomeLineCurve)}
/>
</defs>
<text {...textStyle}>
<textPath xlinkHref="#AliensPath">
Aliens,
</textPath>
</text>
<text {...textStyle}>
<textPath xlinkHref="#GoHomePath">
Go Home!
</textPath>
</text>
</g>
);
};
为了让你的标题有弯曲的效果,你需要使用贝塞尔曲线混合一个path
和textPath
。然后你需要让你的标题像开始按钮一样,静态地定位在画布上。
现在,去你的画布组件添加标题组件吧。把它放在SVG
的最后。嗯,不要忘记import
。
<Title />
如果你现在去页面查看你的项目,你会发现你并不能看见标题。因为你现在的游戏没有足够的垂直方向的空间。
让你的游戏变成响应式
你需要做两件事来改变你的游戏尺寸并让他变成响应式的。首先你需要监听全局对象window
的onresize
事件。这个很简单,打开src/App.js
,添加下面代码到生命周期函数componentDidMount()
中去:
window.onresize = () => {
const cnv = document.getElementById('aliens-go-home-canvas');
cnv.style.width = `${window.innerWidth}px`;
cnv.style.height = `${window.innerHeight}px`;
};
window.onresize();
译者:这里直接使用onresize
事件来缩放画布大小会造成严重的性能问题。不过可能是因为没谁会总去拖动窗口大小,所以作者没有说。感兴趣的可以去看红宝书函数节流那一部分。
这件会使你的画布大小始终和浏览器窗口大小保持一致。就算玩家重新拖动窗口大小,也会保持一致。
第二件事,是你需要添加viewBox
给你的画布。打开Canvas.jsx
,重新计算画布尺寸,把原来定义viewBox
的地方的代码替换成下面的代码:
const gameHeight = 1200;
const viewBox = [window.innerWidth / -2, 100 - gameHeight, window.innerWidth, gameHeight];
现在你把窗口高度值从原来的跟window
的高度一样改成了1200
,于是你就能看见你的标题了。除此之外,变大的区域会让你的玩家更容易地击落飞行物。
const gameHeight = 1200;
移到src/utils/constants.js
里面去。
export const gameHeight = 1200;
让用户可以开始游戏
有了这些组件和足够的尺寸之后,你可以开始思考开始游戏的问题了。这意味着你需要重构你的Game.js
,给它一些状态,每当用户点击开始游戏按钮,你就要开始更新一系列状态来运行游戏。这肯定需要很多的state
。不过,一步一步来,你可以先在点击开始的时候隐藏标题和开始按钮。
要完成这件事,你需要创建一个新的redux acrion
。让它被Redux的reducer
处理来改变你游戏里的标记。修改/src/actions/index.js
来创建这个action
。:
export const MOVE_OBJECTS = 'MOVE_OBJECTS';
export const START_GAME = 'START_GAME';
export const moveObjects = mousePosition => ({
type: MOVE_OBJECTS,
mousePosition,
});
export const startGame = () => ({
type: START_GAME,
});
然后重构/src/reducers/index.js
来修改这个action
:
import { MOVE_OBJECTS, START_GAME } from '../actions';
import moveObjects from './moveObjects';
import startGame from './startGame';
const initialGameState = {
started: false,
kills: 0,
lives: 3,
};
const initialState = {
angle: 45,
gameState: initialGameState,
};
function reducer(state = initialState, action) {
switch (action.type) {
case MOVE_OBJECTS:
return moveObjects(state, action);
case START_GAME:
return startGame(state, initialGameState);
default:
return state;
}
}
export default reducer;
如你所见,initialState
里面现在多了一个子对象,叫做gameState
。它有三个属性:
started
:标记游戏是否开始。kills
:记录玩家击杀了多少个飞行物。lives
:记录玩家的剩几条命。
除此之外,你需要添加一个新的case
条件到你的switch
语句中去。这个新的case
(将会在type
为START_GAME
的action
在reducer
上命中的时候触发。)调用开始游戏的函数。这个函数的目标是改变gameState
中的started
标记。并且,每次用户点击开始游戏,这个函数都要把kills
置零。你需要在/src/reducers
创建一个startGame.js
文件来定义这个函数:
export default (state, initialGameState) => {
return {
...state,
gameState: {
...initialGameState,
started: true,
}
}
};
你也看到了,这个新文件中的代码非常简单。它只返回了一个重新初始化之后的游戏状态对象。这里把生命重置为3条,kill
数重置为零并标记游戏开始。构建完这个函数之后你需要把它传给你的游戏。你还需要传一个新的gameState
。你可以这样修改/src/containers/Game.js
来实现:
import { connect } from 'react-redux';
import App from '../App';
import { moveObjects, startGame } from '../actions/index';
const mapStateToProps = state => ({
angle: state.angle,
gameState: state.gameState,
});
const mapDispatchToProps = dispatch => ({
moveObjects: (mousePosition) => {
dispatch(moveObjects(mousePosition));
},
startGame: () => {
dispatch(startGame());
},
});
const Game = connect(
mapStateToProps,
mapDispatchToProps,
)(App);
export default Game;
总结一下这个文件中的修改:
mapStateToProps
:现在你告诉了Redux,App
组件还需要一个gameState
属性。mapDispatchToProps
:你还告诉了Redux,App
组件需要一个startGame
方法来触发启动游戏的动作。
gameState
和startGame
这两个属性都不会被App
组件直接使用。Canvas
组件才是真正使用它们的人。所以你要把它们传给Canvas
。再次修改/src/App.js
:
// ... import 语句 ...
class App extends Component {
// ... constructor(props) ...
// ... componentDidMount() ...
// ... trackMouse(event) ...
render() {
return (
<Canvas
angle={this.props.angle}
gameState={this.props.gameState}
startGame={this.props.startGame}
trackMouse={event => (this.trackMouse(event))}
/>
);
}
}
App.propTypes = {
angle: PropTypes.number.isRequired,
gameState: PropTypes.shape({
started: PropTypes.bool.isRequired,
kills: PropTypes.number.isRequired,
lives: PropTypes.number.isRequired,
}).isRequired,
moveObjects: PropTypes.func.isRequired,
startGame: PropTypes.func.isRequired,
};
export default App;
然后再重构Canvas.jsx
:
import React from 'react';
import PropTypes from 'prop-types';
import Sky from './Sky';
import Ground from './Ground';
import CannonBase from './CannonBase';
import CannonPipe from './CannonPipe';
import CurrentScore from './CurrentScore'
import FlyingObject from './FlyingObject';
import StartGame from './StartGame';
import Title from './Title';
const Canvas = (props) => {
const gameHeight = 1200;
const viewBox = [window.innerWidth / -2, 100 - gameHeight, window.innerWidth, gameHeight];
return (
<svg
id="aliens-go-home-canvas"
preserveAspectRatio="xMaxYMax none"
onMouseMove={props.trackMouse}
viewBox={viewBox}
>
<defs>
<filter id="shadow">
<feDropShadow dx="1" dy="1" stdDeviation="2" />
</filter>
</defs>
<Sky />
<Ground />
<CannonPipe rotation={props.angle} />
<CannonBase />
<CurrentScore score={15} />
{ ! props.gameState.started &&
<g>
<StartGame onClick={() => props.startGame()} />
<Title />
</g>
}
{ props.gameState.started &&
<g>
<FlyingObject position={{x: -150, y: -300}}/>
<FlyingObject position={{x: 150, y: -300}}/>
</g>
}
</svg>
);
};
Canvas.propTypes = {
angle: PropTypes.number.isRequired,
gameState: PropTypes.shape({
started: PropTypes.bool.isRequired,
kills: PropTypes.number.isRequired,
lives: PropTypes.number.isRequired,
}).isRequired,
trackMouse: PropTypes.func.isRequired,
startGame: PropTypes.func.isRequired,
};
export default Canvas;
如你所见,在这个版本中,你让标题和开始按钮只有在gameState.started
为false
的时候才会显示。并且,你隐藏了飞行物,直到用户点击开始游戏。如果你现在打开网页查看你的应用,你会发现已经有新的交互了。但是这还并不足以让你的玩家玩上游戏,不过你已经在前进了!
让飞行物随机出现
现在你已经构建了开始游戏的方法,你可以开始实现让飞行物随机出现的功能了。他们是你的玩家需要击中的目标所以你还要让他们动起来。不过现在你应该首先专注于让他们随机出现。
你要做的首先是定义这些飞行物应该出现的位置。你同样要设置一些定时器和边界值。为了让代码结构化,你可以在常量文件中维护这些值。那么,打开/src/utils/constants.js
:
// ... 之前的代码不变,添加以下部分
export const createInterval = 1000;
export const maxFlyingObjects = 4;
export const flyingObjectsStarterYAxis = -1000;
export const flyingObjectsStarterPositions = [
-300,
-150,
150,
300,
];
上面这些规则规定了每一秒(1000
毫秒)会出现新的飞行物、同时不会多于4个飞行物(maxFlyingObjects
)并且新的飞行物出现于Y轴的-1000
单位处(flyingObjectsStarterYAxis
)。最后一常量flyingObjectsStarterPositions
定义了飞行物可以出现的四个x轴上的单位。你可以随机选择一个位置来创建新的飞行物。
在/src/reducers
中创建一个createFlyingObjects.js
文件,来构建这个函数:
import {
createInterval, flyingObjectsStarterYAxis, maxFlyingObjects,
flyingObjectsStarterPositions
} from '../utils/constants';
export default (state) => {
if (!state.gameState.started) return state; // game not running
const now = (new Date()).getTime();
const {lastObjectCreatedAt, flyingObjects} = state.gameState;
const createNewObject = (
now - (lastObjectCreatedAt).getTime() > createInterval &&
flyingObjects.length < maxFlyingObjects
);
if (!createNewObject) return state; // no need to create objects now
const id = (new Date()).getTime();
const predefinedPosition = Math.floor(Math.random() * maxFlyingObjects);
const flyingObjectPosition = flyingObjectsStarterPositions[predefinedPosition];
const newFlyingObject = {
position: {
x: flyingObjectPosition,
y: flyingObjectsStarterYAxis,
},
createdAt: (new Date()).getTime(),
id,
};
return {
...state,
gameState: {
...state.gameState,
flyingObjects: [
...state.gameState.flyingObjects,
newFlyingObject
],
lastObjectCreatedAt: new Date(),
}
};
}
这个文件看起来很负责,其实并不。下面列出了它是如何工作的:
- 如果游戏没在运行,
! state.gameState.started
。这段代码原封不动返回当前的状态。 - 如果游戏正在运行,这个函数依据
createInterval
和maxFlyingObjects
是否创建新的飞行物。这个逻辑在createNewObject
中维护。 - 如果
createNewObject
的返回值为true
,这个函数使用Math.floor
在0-3之间取一个整数Math.random() * maxFlyingObjects
来决定新创建的飞行物的位置。 - 根据这些逻辑,这个函数创建一个叫
newFlyingObject
,并确定它的位置。 - 在最后这个函数返回一个新创建的飞行物的状态对象,并更新上一次创建飞行物的时间
lastObjectCreatedAt
。
如你所见,这个文件只是创建了一个reducer
。根据前面的经验,你可能觉得这里应该创建一个action
来触发reducer
但是事实上并不需要。因为你的游戏提出了一个MOVE_OBJECTS
类型的方法,每10s会调用一次(参考第一部分moveObjects)。你可以利用这个动作来触发你的reducer
。为了实现这个,你需要重构/src/reducers/moveObjects.js
中的moveObjects
:
import { calculateAngle } from '../utils/formulas';
import createFlyingObjects from './createFlyingObjects';
function moveObjects(state, action) {
const mousePosition = action.mousePosition || {
x: 0,
y: 0,
};
const newState = createFlyingObjects(state);
const { x, y } = mousePosition;
const angle = calculateAngle(0, 0, x, y);
return {
...newState,
angle,
};
}
export default moveObjects;
这次做出的修改如下:
- 对
mousePosition
做了空值处理。 - 它从
createFlyingObjects
获取了新的飞行物状态。 - 最后,把之前取来的状态进行重新包装并返回
newState
。
在开始重构App
组件和Canvas
组件之前,你需要先修改一下/src/reducers/index.js
。添加两个新的属性给initialState
:
// ... import statements ...
const initialGameState = {
// ... other initial properties ...
flyingObjects: [],
lastObjectCreatedAt: new Date(),
};
// ... everything else ...
有了这些,你接下来要做的只有更新App
的数据类型验证部分了:
// ... import statements ...
// ... App component class ...
App.propTypes = {
// ... other propTypes definitions ...
gameState: PropTypes.shape({
// ... other propTypes definitions ...
flyingObjects: PropTypes.arrayOf(PropTypes.shape({
position: PropTypes.shape({
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired
}).isRequired,
id: PropTypes.number.isRequired,
})).isRequired,
// ... other propTypes definitions ...
}).isRequired,
// ... other propTypes definitions ...
};
export default App;
然后让Canvas
组件对这个飞行物属性进行迭代,渲染所有的飞行物。你还要确保,飞行物的位置不再是之前写的静态地位置了,要从FlyingObject
中获取。
// ... import statements ...
const Canvas = (props) => {
// ... const definitions ...
return (
<svg ... >
// ... other SVG elements and React Components ...
{props.gameState.flyingObjects.map(flyingObject => (
<FlyingObject
key={flyingObject.id}
position={flyingObject.position}
/>
))}
</svg>
);
};
Canvas.propTypes = {
// ... other propTypes definitions ...
gameState: PropTypes.shape({
// ... other propTypes definitions ...
flyingObjects: PropTypes.arrayOf(PropTypes.shape({
position: PropTypes.shape({
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired
}).isRequired,
id: PropTypes.number.isRequired,
})).isRequired,
}).isRequired,
// ... other propTypes definitions ...
};
export default Canvas;
就是这样!现在你在点击开始游戏,就会随机出现飞行物了。
注意:这时候你运行游戏的时候,有一种可能:直到最后你也看不到三个飞行物。这是因为,我们没有做重复位置检测,飞行物可能出现在同一位置重叠了。下一部分我们会做这件事。
使用CSS动画来让飞行物动起来
你有两种方式来让飞行物移动起来。第一种,最直接的,使用JavaScript
来改变飞行物的位置。这种方式看起来很好实现,但是会降低我们游戏的性能。
第二种就是利用CSS
动画了。这种方式将会使用GPU
进行渲染,会提升我们游戏的性能。
你可能觉得这种实现方式太难了。其实也并不难。一种巧妙的实现方式是使用一个NPM
包提供的CSS
动画合计和React
属性。他就是styled-components
。
“利用模板字符串和CSS的力量,styled-components让你可以在组件中书写原生的CSS来给你的组件赋予样式。他同时也移除了组件和样式之间的映射关系—— using components as a low-level styling construct could not be easier .” —— styled-components
译者:知识匮乏了,上面有一句没翻译,谁知道低级样式结构(low-level styling construct
)指的是什么请告诉我一下,非常感谢。
我们可以通过npm
来安装这个包。
npm i styled-components --save
安装完成之后,你可像这样重构FlyingObject
:
import React from 'react';
import PropTypes from 'prop-types';
import styled, { keyframes } from 'styled-components';
import FlyingObjectBase from './FlyingObjectBase';
import FlyingObjectTop from './FlyingObjectTop';
import { gameHeight } from '../utils/constants';
const moveVertically = keyframes`
0% {
transform: translateY(0);
}
100% {
transform: translateY(${gameHeight}px);
}
`;
const Move = styled.g`
animation: ${moveVertically} 4s linear;
`;
const FlyingObject = props => (
<Move>
<FlyingObjectBase position={props.position} />
<FlyingObjectTop position={props.position} />
</Move>
);
FlyingObject.propTypes = {
position: PropTypes.shape({
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired
}).isRequired,
};
export default FlyingObject;
在这个版本中,你把FlyingObjectBase
和FlyingObjectTop
包裹在了一个Move
组件中。这个组件是一个简单的g
标签。他应用了moveVertically
变形。关于如何使用CSS变形和styled-components
,请参考官网和MDN 上面的如何使用css动画。
最后,你的飞行物会从最开始定义的标准位置(transform: translateY(0);
)缓缓降落到最底部(transform: translateY(${gameHeight}px);
)。
你最好修改一下/src/utils/constants.js
文件。把里面定义的flyingObjectsStarterYAxis
的值改一下,改成一个屏幕顶上外面的值,让飞行物最开始不可见,而是从天上掉下来的。
//比如你可以改成 -1100
export const flyingObjectsStarterYAxis = -1100;
最后,你需要在飞行物出现四秒之后销毁它们。这样新的飞行物才会加入到画布中来。你可以通过编辑/src/reducers/moveObjects.js
来实现:
import { calculateAngle } from '../utils/formulas';
import createFlyingObjects from './createFlyingObjects';
function moveObjects(state, action) {
const mousePosition = action.mousePosition || {
x: 0,
y: 0,
};
const newState = createFlyingObjects(state);
const now = (new Date()).getTime();
const flyingObjects = newState.gameState.flyingObjects.filter(object => (
(now - object.createdAt) < 4000
));
const { x, y } = mousePosition;
const angle = calculateAngle(0, 0, x, y);
return {
...newState,
gameState: {
...newState.gameState,
flyingObjects,
},
angle,
};
}
export default moveObjects;
如你所见,这些新的代码过滤了flyingObjects
。对于创建时间到当前时间大于四秒的飞行物进行销毁。
这时候你重启你的应用就会看见降落的飞行物了,他们从顶部降落到底部并且同时始终保持三个。
结论和下一步
在本系列的第二部分,你创建了你所需要的大部分React元素。你也拥有了随机出现的飞行物,并且使用CSS
让他们平滑地运动起来了。
在下一部分,也就是本系列的最终章,你将会实现你的游戏缺失的最后一些功能。比如让你的加农炮射击飞碟,控制玩家的剩余命数等等。
敬请期待!