【翻译】使用React、 Redux 和 SVG 开发游戏(二)

732 阅读22分钟

使用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组件。我们做了多个组件比如天空组件、陆地组件还有加农炮体和炮管。最后我们通过使用计时器,监听事件然后触发reduxaction来更新炮管的角度让加农炮有了瞄准的能力。 这些东西为你铺平了接下来的道路。

注意:不管什么原因,如果你没有第一部分的代码,你可以到这里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组件,才能在你的画布上显示炮弹。传入的这个对象需要包含xy两个数字类型的属性。如果你对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;

然后你的游戏现在是这个样子了。

image
还特么可以把?huh?(译者:浓浓的小猪佩奇风

创建FO组件

来创建一个代表飞行物的组件怎么样?飞行物可不是简单的圆儿或者方块儿什么的基本图形。它是由两部分组成的:顶帽和主体。所以,像之前的加农炮一样,我们也创建两部分组件:FlyingObjectBase.jsxFlyingObjectTop.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

现在你的游戏长这样了:

image

创建心❤️组件

下一个组件是用来表示当前玩家剩余机会数的,用小红心来表示。所以,这个组件叫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;

不同于❤️组件,这个组件只有一个所以也不需要什么位置属性传入了,直接在组件内部根据画布大小来计算一个位置就可以了。另外,这个组件跟你之前定义的组件还有两个不同点:

  1. 这个组件需要一个函数参数onClice。用来监听玩家的鼠标点击事件并处罚reactaction来让你的应用开始一场新的游戏。
  2. 这个组件用了一个你还没有定义的常量叫做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>
  );
};

为了让你的标题有弯曲的效果,你需要使用贝塞尔曲线混合一个pathtextPath。然后你需要让你的标题像开始按钮一样,静态地定位在画布上。

现在,去你的画布组件添加标题组件吧。把它放在SVG的最后。嗯,不要忘记import

<Title />

如果你现在去页面查看你的项目,你会发现你并不能看见标题。因为你现在的游戏没有足够的垂直方向的空间。

让你的游戏变成响应式

你需要做两件事来改变你的游戏尺寸并让他变成响应式的。首先你需要监听全局对象windowonresize事件。这个很简单,打开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,于是你就能看见你的标题了。除此之外,变大的区域会让你的玩家更容易地击落飞行物。

image
这里我们最好把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(将会在typeSTART_GAMEactionreducer上命中的时候触发。)调用开始游戏的函数。这个函数的目标是改变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方法来触发启动游戏的动作。

gameStatestartGame这两个属性都不会被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.startedfalse的时候才会显示。并且,你隐藏了飞行物,直到用户点击开始游戏。如果你现在打开网页查看你的应用,你会发现已经有新的交互了。但是这还并不足以让你的玩家玩上游戏,不过你已经在前进了!

让飞行物随机出现

现在你已经构建了开始游戏的方法,你可以开始实现让飞行物随机出现的功能了。他们是你的玩家需要击中的目标所以你还要让他们动起来。不过现在你应该首先专注于让他们随机出现。

你要做的首先是定义这些飞行物应该出现的位置。你同样要设置一些定时器和边界值。为了让代码结构化,你可以在常量文件中维护这些值。那么,打开/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(),
		}
	};
}

这个文件看起来很负责,其实并不。下面列出了它是如何工作的:

  1. 如果游戏没在运行,! state.gameState.started。这段代码原封不动返回当前的状态。
  2. 如果游戏正在运行,这个函数依据createIntervalmaxFlyingObjects是否创建新的飞行物。这个逻辑在createNewObject中维护。
  3. 如果createNewObject的返回值为true,这个函数使用Math.floor在0-3之间取一个整数Math.random() * maxFlyingObjects来决定新创建的飞行物的位置。
  4. 根据这些逻辑,这个函数创建一个叫newFlyingObject,并确定它的位置。
  5. 在最后这个函数返回一个新创建的飞行物的状态对象,并更新上一次创建飞行物的时间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;

这次做出的修改如下:

  1. mousePosition做了空值处理。
  2. 它从createFlyingObjects获取了新的飞行物状态。
  3. 最后,把之前取来的状态进行重新包装并返回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;

在这个版本中,你把FlyingObjectBaseFlyingObjectTop包裹在了一个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让他们平滑地运动起来了。

在下一部分,也就是本系列的最终章,你将会实现你的游戏缺失的最后一些功能。比如让你的加农炮射击飞碟,控制玩家的剩余命数等等。

敬请期待!