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

ZephyrJS發表於2018-04-15

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


React 遊戲:Aliens, Go Home!

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

如果您很好奇, 您可以找到 the final game up and running here。但別太沉迷其中,您還要完成它的開發!

“我用 React、Redux 和 SVG 建立了一個遊戲。”

前文概要 Part 1

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

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

**注意:**不管出於什麼原因,如果您沒有 本系列第一部分 的原始碼,您可以很容易的從 這個 GitHub 倉庫 進行克隆。在克隆完之後,您只需要按照下面幾節中的說明進行操作即可。

建立更多的 React 元件

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

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

建立 Cannonball 元件

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

為了建立這元件,需要在 ./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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章