[譯] 使用 React、Redux 和 SVG 開發遊戲 - Part 2

TL;DR: 在這個系列裏,您將學會用 React 和 Redux 來控制一些 SVG 元素來建立一個遊戲。經過本系列的學習,您不只能建立遊戲,還能用 React 和 Redux 來開發其餘類型的動畫。源碼請參考 GitHub 倉庫:Aliens Go Home - Part 2css


React 遊戲:Aliens, Go Home!

在這個系列裏您將要開發的遊戲叫作 Aliens, Go Home! 這個遊戲的想法很簡單,您將擁有一座炮臺,而後您必須消滅那些試圖入侵地球的飛碟。爲了消滅這些飛碟,您必須在 SVG 畫布上經過瞄準和點擊來操做炮臺的射擊。前端

若是您很好奇, 您能夠找到 the final game up and running here。但別太沉迷其中,您還要完成它的開發!react

「我用 React、Redux 和 SVG 建立了一個遊戲。」android

前文概要 Part 1

本系列的第一部分,您使用 create-react-app 來開始您的 React 應用並安裝和配置了 Redux 來管理遊戲的狀態。以後,您學會了如何將 SVG 和 React 組合在一塊兒來建立諸如 SkyGroundCannonBaseCannonPipe 等遊戲元素。最後,爲了給炮臺添加瞄準功能,您使用了一個事件監聽器和 JavaScript interval 觸發 Redux action 來更新 CannonPipe 的角度。ios

前面的這些學習是爲了更好地理解如何使用 React、Redux 和 SVG 來建立遊戲(或動畫)而作準備。git

**注意:**無論出於什麼緣由,若是您沒有 本系列第一部分 的源碼,您能夠很容易的從 這個 GitHub 倉庫 進行克隆。在克隆完以後,您只須要按照下面幾節中的說明進行操做便可。github

建立更多的 React 組件

下面的幾節將向您展現如何建立其他的遊戲元素。儘管它們看起來很長,但它們都很是的簡單和類似。按照指示去作,您可能幾分鐘就搞定了。npm

在這以後,您將看到本章最有趣的部分。它們分別是 使飛碟隨機出現使用 CSS 動畫移動飛碟redux

建立 Cannonball 組件

接下來您將建立 CannonBall 組件。請注意,目前它還不會動。但別擔憂!很快(在建立完其餘組件以後),您將用炮臺發射多個炮彈並殺死一些外星人。canvas

爲了建立這組件,須要在 ./src/components 建立 CannonBall.jsx 文件並添加以下代碼:

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; 複製代碼

如您所見,要使炮彈出如今畫布中,您必須向它傳遞一個包含 xy 屬性的對象。若是您對 prop-types 還不熟,這多是您第一次使用 PropTypes.shape。幸運的是,這個特性不言自明。

建立此組件後,您可能但願在畫布上看到它。爲此,在 Canvas 組件裏的 svg 元素中添加以下代碼(固然您還須要加上 import CannonBall from './CannonBall';):

<CannonBall position={{x: 0, y: -100}}/>
複製代碼

請記住,若是把它放在同一位置的元素以前,您將看不到它。所以,爲了安全起見,將把它放在最後(就是 <CannonBase /> 以後)。以後,您就能夠在瀏覽器裏看到您的新組件了。

若是您忘記了怎麼操做的,您只需在項目根目錄運行 npm start 而後在瀏覽器打開 http://localhost:3000 。此外,千萬別忘記在進行下一步以前把代碼提交到您的倉庫裏。

建立 Current Score 組件

接下來您將建立另外一個組件 CurrentScore。顧名思義,您將使用該組件向用戶顯示他們當前的分數。也就是說,每當他們消滅一隻飛碟時,在這個組件中表明分數的值將會加一,並顯示給他們。

在建立此組件以前,您可能須要添加並使用一些漂亮字體。實際上,您可能但願在整個遊戲中配置和使用字體,這樣看起來就不會像一個單調的遊戲了。您能夠從任何地方瀏覽並選擇一種字體,但若是您想不花時間在這個上面,您只需在 ./src/index.css 文件的頂部添加以下代碼便可:

@import url('https://fonts.googleapis.com/css?family=Joti+One');

/* other rules ... */
複製代碼

這將使您的遊戲載入 來自 Google 的 Joti One 字體

以後,您能夠在 ./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)"> <text style={scoreStyle} x="300" y="80"> {props.score} </text> </g>
  );
};

CurrentScore.propTypes = {
  score: PropTypes.number.isRequired,
};

export default CurrentScore;
複製代碼

注意: 若是您還沒有配置 Joti One(或者配置了其餘字體),您將須要修改相應的代碼。若是您之後建立的其餘組件也會用到該字體,請記住,您也須要更新這些組件。

如您所見,CurrentScore 組件僅須要一個屬性:score。因爲您的遊戲尚未計算分數,爲了立刻看到這個組件,您須要傳入一個硬編碼的值。所以,在 Canvas 組件裏,往 svg 中末尾添加 <CurrentScore score={15} />。另外,還須要添加 import 語句來獲取這個組件(import CurrentScore from './CurrentScore';)。

若是您想如今就看到新組件,您將沒法如願以償。這是由於組件使用了叫作 shadowfilter。儘管它不是必須的,但它將使您的遊戲更加好看。另外,給 SVG 元素添加陰影是十分簡單的。爲此,僅須要在 svg 頂部添加以下代碼:

<defs>
  <filter id="shadow"> <feDropShadow dx="1" dy="1" stdDeviation="2" /> </filter> </defs>
複製代碼

最後,您的 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;
複製代碼

而您的遊戲看起來將會是這樣:

Showing current score and cannonball in the Alien, Go Home! app.

還不錯,對吧?!

建立 Flying Object 組件

如今如何建立 React 組件來展現飛碟呢?飛碟既不是圓形,也不是矩形。它們一般有兩個部分 (頂部和底部),這些部分通常是圓形的。這就是爲何您將須要用 FlyingObjectBaseFlyingObjectTop 這個組件來建立飛碟的緣由。

其中一個組件將使用貝塞爾三次曲線來定義其形狀。另外一個則是一個橢圓。

先從第一個組件 FlyingObjectBase 開始,在 ./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; 複製代碼

以後,您能夠定義飛碟的頂部。爲此,在 ./src/components 目錄下建立 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;
複製代碼

如今,想要在遊戲中添加飛碟,只需使用一個 React 組件便可。爲了達到目的,在 Canvas 組件添加以下代碼:

// ... other imports
import FlyingObject from './FlyingObject';

const Canvas = (props) => {
  // ...
  return (
    <svg ...>
      // ...
      <FlyingObject position={{x: -150, y: -300}}/>
      <FlyingObject position={{x: 150, y: -300}}/>
    </svg>
  );
};

// ... propTypes and export
複製代碼

Creating flying objects in your React game

建立 Heart 組件

接下來您須要建立顯示玩家生命值的組件,沒有什麼詞是比用 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 建立心形,您須要兩條三次 Bezier 曲線:愛心的兩邊各一條。您還須向該組件添加一個 position 屬性。這是由於遊戲會給玩家提供不僅一條生命,因此這些愛心須要顯示在不一樣的位置。

如今,您能夠先將一顆心添加到畫布中,這樣您就能夠確認一切工做正常。爲此,打開 Canvas 組件並添加以下代碼:

<Heart position={{x: -300, y: 35}} />
複製代碼

這必須是 svg 裏最後一個元素。另外,別忘了添加 import 語句(import Heart from './Heart';)。

建立 Start Game 按鈕組件

每一個遊戲都須要一個開始按鈕。所以,爲了建立它,在其餘組件旁建立 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; 複製代碼

因爲不須要同時顯示多個 StartGame 按鈕,您須要爲該組件在遊戲裏設置固定的位置(x: 0 and y: -150)。該組件與您以前定義的其餘組件之間還有另外兩個不一樣之處:

  • 首先,這個組件須要一個名爲 onClick 的函數。這個函數是用來監聽按鈕點擊事件,並將觸發一個 Redux action 來使您的應用開始一個新的遊戲。
  • 其次,這個組件正在使用一個您尚未定義的常量 gameWidth。這個常數將表示可用的區域。除了您的應用所佔據的位置以外,其餘區域都將不可用。

爲了定義 gameWidth 常量,須要打開 ./src/utils/constants.js 文件並添加以下代碼:

export const gameWidth = 800;
複製代碼

以後,您能夠將 StartGame 組件添加到 Canvas 中,方式是往 svg 元素中的末尾添加 <StartGame onClick={() => console.log('Aliens, Go Home!')} />。跟以前同樣,別忘了添加 import 語句(import StartGame from './StartGame';)。

Aliens, Go Home! game with the start game button

建立 Title 組件

Title 組件是本篇文章您將建立最後一個組件. 您已經爲您的遊戲起了名字了:Aliens, Go Home!。所以,建立 Title.jsx(在 ./src/components 目錄下)文件來做爲標題並添加以下代碼:

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>
  );
};

export default Title;
複製代碼

爲了使標題彎曲顯示,您使用了 pathtextPath 元素與三次貝塞爾曲線的組合。此外,您還使用了固定的座標位置,就像 StartGame 按鈕組件那樣。

如今,要將該組件添加到畫布中,只需將 <title/> 組件添加到 svg 元素中,並在 Canvas.jsx 文件的頂部添加 import 語句便可(import Title from './Title';)。可是,若是您如今運行您的應用程序,您將發現您的新組件沒有出如今屏幕上。這是由於您的應用程序尚未足夠的垂直空間用於顯示。

讓您的 React Game遊戲自適應

爲了改變遊戲的尺寸並使其自適應,您將須要作如下兩件事。首先,您將須要添加 onresize 事件監聽器到全局 window 對象上。很簡單,您僅須要打開 ./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();
複製代碼

這將使您應用的大小和用戶看到的窗口大小保持一致,即便他們改變了窗口大小也不要緊。當應用程序第一次出現時,它還將強制執行 window.onresize 函數。

其次,您須要更改畫布的 viewBox 屬性。如今,不須要再 Y 軸上定義最高點:100 - window.innerHeight(若是您不記得爲何要使用這個公式,請看一下本系列的第一部分)而且 viewBox 高度等於 window 對象上 innerHeight 的值,下列使您將用到的代碼:

const gameHeight = 1200;
const viewBox = [window.innerWidth / -2, 100 - gameHeight, window.innerWidth, gameHeight];
複製代碼

在這個新版本中,您使用的值爲 1200,這樣您的應用就能正確地顯示新的標題組件。此外,這個新的垂直空間將給您的用戶足夠的時間來看到和消滅那些外星飛碟。這將給到他們足夠的時間來射擊和消滅這些飛碟。

Changing your React, Redux, and SVG game dimensions and making it responsive

讓用戶開始遊戲

當把這些新組件按的尺寸放在對應的位置之後,您就能夠開始考慮怎麼讓用戶開始玩遊戲了。不管什麼時候,當用戶點了 Start Game 這個按鈕,您就須要能遊戲切換到開始狀態,這將致使遊戲一連串的狀態變化。爲了更便於用戶操做,當用戶點擊了這個按鈕的時候,您就能夠開始將 TitleStartGame 這兩個組件從當前的屏幕上移除。

爲此,您將須要建立一個新的 Redux action,它將傳入到 Redux reducer 中來改變遊戲的狀態。爲了建立這個新的 action,打開 ./src/actions/index.js 並添加以下代碼(保留以前的代碼不變):

// ... MOVE_OBJECTS
export const START_GAME = 'START_GAME';

// ... moveObjects

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 中有一個子對象,它包含三個跟遊戲有關的屬性:

  1. started: 一個表示是否開始運行遊戲的標識;
  2. kills: 一個保存用戶消滅的飛碟數量的屬性;
  3. lives: 一個保存用戶還有多少條命的屬性;

此外,您還須要在 switch 語句中添加一個新的 case。這個新的 case (包含 type START_GAME 的 action 傳入到 reducer 時觸發)調用 startGame 函數。這個函數的做用是將 gameState 裏的 started 屬性設置爲 true。此外,每當用戶開始一個新的遊戲,這個函數將 kills 計數器設置爲零並讓用戶一開始有三條命。

要實現 startGame 函數,須要在 ./src/reducers 目錄下建立 startGame.js 文件並添加以下代碼:

export default (state, initialGameState) => {
  return {
    ...state,
    gameState: {
      ...initialGameState,
      started: true,
    }
  }
};
複製代碼

如您所見,這個新文件中的代碼很是簡單。它只是返回新的 state 對象到 Redux store 中,並將 started 設置爲 true 同時重置 gameState 中的全部其餘屬性。這將使用戶再次得到三條命,並將 kills 計數器設置爲零。

實現這個函數以後,您必須將其傳遞給您的遊戲。您還須將新的 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: 如今,App 組件關注 gameState 屬性已經告知了 Redux。
  • mapDispatchToProps: 您也告知了 Redux 須要將 startGame 函數傳遞給 App 組件,這樣它就能夠觸發這個新 action。

這些新的 App 屬性(gameStatestartGame)不會被 App 組件直接使用。實際上,使用它們的是 Canvas 組件,因此您必須將它們傳遞給它。所以,打開 ./src/App.js 文件並按以下方式重構:

// ... import statements ...

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; 複製代碼

而後,打開 ./src/components/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 時 StartGameTitle 纔會可見。此外,您還隱藏了 FlyingObject 組件直到用戶點擊 Start Game 按鈕纔會出現。

若是您如今運行您的應用程序(若是它尚未運行,在 terminal 裏運行 npm start),您將看到這些新的變化。雖然用戶還不能玩您的遊戲,但您已經完成一個小目標了。

讓飛碟隨機出現

如今您已經實現了 Start Game 功能,您能夠重構您的遊戲來讓飛碟隨機出現。您的用戶須要消滅一些飛碟,因此您還須要讓它們飛起來(即往屏幕下方移動)。但首先,您必須集中精力讓它們以某種方式出現。

要作到這一點,第一件事是定義這些對象將出如今何處。您還必須給飛行物體設置一些間隔和最大數量。爲了使事情井井有理,您能夠定義常量來保存這些規則。因此,打開 ./src/utils/constants.js 文件添加以下代碼:

// ... keep skyAndGroundWidth and gameWidth untouched

export const createInterval = 1000;

export const maxFlyingObjects = 4;

export const flyingObjectsStarterYAxis = -1000;

export const flyingObjectsStarterPositions = [
  -300,
  -150,
  150,
  300,
];
複製代碼

上面的規則規定遊戲將每秒(1000 毫秒)出現新的飛碟,同一時間不會超過四個(maxFlyingObjects)。它還定義了新對象在 Y 軸(flyingObjectsStarterYAxis)上出現的位置爲 -1000。文件中最後一個常量(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),這代碼返回當前未更改的 state。
  2. 若是遊戲正在運行,這個函數依據 createIntervalmaxFlyingObjects 常量來決定是否建立新的飛行對象。這些邏輯構成了 createNewObject 常量。
  3. 若是 createNewObject 常量的值設置爲 true,這個函數使用 Math.floor 獲取 0 到 3 的隨機數(Math.random() * maxFlyingObjects)來決定新的飛碟將出如今哪。
  4. 有了這些數據,這個函數將建立帶有 position 屬性 newFlyingObject 對象。
  5. 最後,該函數返回一個帶有新飛行對象的新狀態對象,並更新 lastObjectCreatedAt 的值。

您可能已經注意到,您剛剛建立的函數是一個 reducer。所以,您可能但願建立一個 action 來觸發這個 reducer,但事實上您並不須要這樣作。由於您的遊戲有一個每 10 毫秒觸發一個 MOVE_OBJECTS 的 action,您能夠利用這個 action 來觸發這個新的 reducer。所以,您必須按以下方式從新實現 moveObjects reducer(./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 { x, y } = mousePosition;
  const angle = calculateAngle(0, 0, x, y);
  return {
    ...newState,
    angle,
  };
}

export default moveObjects;
複製代碼

新版本的 moveObjects reducer 跟以前不同的有:

  • 首先,若是在 action 對象中沒有傳入 mousePosition 常量,則強制建立它。這樣作的緣由是若是沒有傳遞 mousePosition 則上一個版本 reducer 將中止運行。
  • 其次,它從 createFlyingObjects reducer 中獲取 newState 對象,以便在須要的時候建立新的飛碟。
  • 最後,它會根據上一步檢索到的 newState 對象返回新的對象。

在重構 AppCanvas 組件來經過這段的代碼顯示新的飛碟前,您將須要更新 ./src/reducers/index.js 文件來給 initialState 對象添加兩個新屬性:

// ... import statements ...

const initialGameState = {
  // ... other initial properties ...
  flyingObjects: [],
  lastObjectCreatedAt: new Date(),
};

// ... everything else ...
複製代碼

這樣作以後,您須要作的就是在 App 組件的 propTypes 對象中添加 flyingObjects

// ... 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;
複製代碼

就是這樣!如今,在用戶開始遊戲時,您的應用程序將建立並隨機顯示飛碟。

注意: 若是您如今運行您的應用程序並點擊 Start Game 按鈕,您最終可能只看到一隻飛碟。 這是由於沒有什麼能阻止飛碟出如今 X 軸相同的位置。在下一節中,您將使您的飛行物體沿着 Y 軸移動。這將確保您和您的用戶可以看到全部的飛碟。

使用 CSS 動畫來移動飛碟

有兩種方式可讓您的飛碟移動。第一種顯而易見的方式是使用 JavaScript 代碼來改變他們的位置。儘管這種方法看起來很容易實現,但它事實上是行不通的,由於它會下降遊戲的性能。

第二種也是首選的方法是使用 CSS 動畫。這種方法的優勢是它使用 GPU 對元素進行動畫處理,從而提升了應用程序的性能。

您可能認爲這種方法很難實現,但如您所見,事實卻並不是如此。最棘手的部分是,您將須要另外一個 NPM 包來將 CSS 動畫和 React 結合起來。也就是說,您須要安裝 styled-components

「經過使用標記模板字面量(JavaScript 最新添加)和 CSS 的強大功能,styled-components 容許您使用原生的 CSS 代碼定義您組件的樣式。它也刪除了 components 和 styles 之間的映射 —— 將組件用做低級樣式構造是不容易的!」styled-components

要安裝這個 package,您須要中止您的 React 應用(即他已經啓動和正在運行)並使用如下命令:

npm i styled-components
複製代碼

安裝完之後,您可使用下列代碼替換 FlyingObject 組件(./src/components/FlyingObject.jsx):

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 裏面。這個組件只是使用一個 moveVertically 變換來定義 SVG 的 g 元素的 styled 樣式。爲了學習更多關於變換的知識以及如何使用 styled-components,您能夠在這裏查閱 官方文檔 以及 MDN 網站上的 使用 CSS 動畫 來學習這些知識。

最後,爲了替換純的/不動的飛碟,您須要添加帶有 transformation(一個 CSS 規則)的飛碟,它們將從起始位置(transform: translateY(0);)移動到遊戲的底部(transform: translateY(${gameHeight}px);)。

固然,您必須將 gameHeight 常量添加到 ./src/utils/constants.js 文件中。另外,因爲您須要更新該文件,因此您能夠替換 flyingObjectsStarterYAxis 來使對象在用戶看不到的位置啓動。但如今的當前值倒是飛碟恰好出如今可視區域的中央,這會令最終用戶感到奇怪。

爲了更正它,您須要打開 constants.js 文件並進行以下更改:

// keep other constants untouched ...

export const flyingObjectsStarterYAxis = -1100;

// keep flyingObjectsStarterPositions untouched ...

export const gameHeight = 1200;
複製代碼

最後,你須要在 4 秒後消滅飛碟,這樣新的飛碟將會出現並在畫布中移動。爲了實現這一點,您能夠在 ./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;
複製代碼

如您所見,咱們爲 gameState 對象的 flyingObjects 屬性添加了新的代碼過濾器,它移除了大於或等於 4000(4 秒)的對象。

若是您如今從新啓動您的應用程序(npm start)並點擊 Start Game 按鈕,您將看到飛碟在畫布中自頂向上地移動。此外,您會注意到,遊戲在建立新的飛碟以後,現有的飛碟都會移動到畫布的底部。

Using CSS animation with React

"在 React 中使用 CSS 動畫是很簡單的,並且會提升您應用的性能。"

總結和下一步

在本系列的第二部分中,您經過使用 React、Redux 和 SVG 建立了您遊戲所需大部分元素。最後,您還使飛碟不一樣的位置隨機出現,並利用 CSS 動畫,使他們順利飛行。

在本系列的下一篇也是最後一篇中,您將實現遊戲剩餘的功能。也就是說,您將實現:使用您的大炮消滅飛碟;控制您的用戶的生命條;以及記錄您的用戶將會殺死多少隻飛碟。您還將使用 Auth0Socket.IO 來實現實時排行榜。請繼續關注!


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索