阅读 516

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

使用React、 Redux 和 SVG 开发游戏(三)

本文翻译自:Developing Games with React, Redux, and SVG - Part 3

转载英文原作请注明原作者与出处。转载本译本请注明译者译者博客

前情回顾

在上一部分,你创建了你需要的其他游戏元素(Heart,FlyingObj和CannonBall)。然后让玩家能够点击按钮来开始游戏。你还使用CSS来让你的飞行物运动起来。 尽管有这些功能已经很酷了,但是他们还不足以完成这个游戏。你还需要让你的加农炮发射炮弹并攻击飞行物。你需要实现一个算法来确认炮弹与飞行物之间的碰撞。能够 击落外星人的飞行物并让分数上涨这听起来已经很不错了,但是你可以让它更有趣。你可以制作一个排行榜,这会让你的玩家全力游戏去达到这个榜上的目标。 有了这些功能,你就开发完成一个完整的游戏了。那么,不浪费时间了,我们开始吧!

注意:不管什么原因,如果你没有前面的代码,你可以从这个Github仓库克隆一个。

在你的React游戏中实现排行榜

为了让你的游戏看起来更真实,你首先要做的事情应该是做一个排行榜功能。这个功能可以让你的玩家注册,然后你的游戏可以追踪他们的最高分并展示他们的等级。

集成React和Auth0

译者:这篇文章的原作者发布在Auth0这个网站。并且这个网站提供SSO(单点登录)的功能。作者在接下来这一段的思路就是连接到Auth0网的单点登录,从那边登录获取你游戏的登录态。这一整个章节都是典型的SSO接入教程。这里,如果能翻墙,可以直接按照教程来。如果没有,我会寻找一种代替方案发布出来。

为了让Auth0来管理你的玩家, 你需要先有一个Auth0的账号,如果你没有,请到这里注册:Auth0 注册

注册完成之后,你需要创建[Auth0应用](Auth0 Application)来代表你的游戏。在Auth0的dashboard打开应用程序页面并点击Create Application按钮。dashboard会给你一个表单让你填应用的名字和类型。你可以起名就叫Aliens , Go Home !然后选择类型为Single Page Application,最后点击创建。

当你点击了这个按钮,Dashboard会将你重定向到Quick Start页签。因为你将学习的是如何集成Auth0和React,所以你并不需要这个页,你需要的是前往Settings页。

在这里你有三件事情要做,第一件事情是添加localhost://3000到回调函接口的输入框(Allowed Callback URLs)。这个就是玩家登陆成功后,Auth0回调的你的游戏地址。如果你以后将你的游戏发布到外网上,那你还需要把你发布后的网址加进去。

输入完所有的URLs之后,点击保存按钮。

最后你要做的两件事就是复制你的应用ID和你的Auth0域名。不过在这之前你还需要敲一点点代码。

首先,你需要先安装Auth0的web包:

npm install auth0-web@1.6.1
复制代码

译者:这里的版本号是我加上去的。现在auth0-web的版本已经更新到了2.2.0。是不兼容更新。不加版本号安装的是最新版本。

下一步是添加一个登陆按钮,来让你的玩家可以登陆并记录分数。我们在./src/components创建一个新的组件叫做Login.jsx

import React from 'react';
import PropTypes from 'prop-types';

const Login = (props) => {
	const button = {
		x: -300, // half width
		y: -600, // minus means up (above 0)
		width: 600,
		height: 300,
		style: {
			fill: 'transparent',
			cursor: 'pointer',
		},
		onClick: props.authenticate,
	};

	const text = {
		textAnchor: 'middle', // center
		x: 0, // center relative to X axis
		y: -440, // 440 up
		style: {
			fontFamily: '"Joti One", cursive',
			fontSize: 45,
			fill: '#e3e3e3',
			cursor: 'pointer',
		},
		onClick: props.authenticate,
	};

	return (
		<g filter="url(#shadow)">
			<rect {...button} />
			<text {...text}>
				Login to participate!
			</text>
		</g>
	);
};

Login.propTypes = {
	authenticate: PropTypes.func.isRequired,
};

export default Login;
复制代码

你还要像之前一样把这个组件添加到你的画布里去。打开Canvas.jsx

// ... 其他的导入语句
import Login from './Login';
import { signIn } from 'auth0-web';

const Canvas = (props) => {
  // ... const definitions
  return (
    <svg ...>
      // ... 其他的元素
      { ! props.gameState.started &&
      <g>
        // ... StartGame and Title components
        <Login authenticate={signIn} />
      </g>
      }

      // ... 飞行物映射
    </svg>
  );
};
// ... propTypes 定义和导出语句

复制代码

如你所见,在这个新的版本里你引入了登录组件和auth0-web提供的signin方法。我们点击按钮的时候应该触发signIn方法。

你要做的最后一件事是配置auth0-web。打开App.js

// ... 其他导入语句
import * as Auth0 from 'auth0-web';

Auth0.configure({
  domain: '你的Auth0的域名,你注册的时候填的',
  clientID: '你的应用ID,在你的dashboard里复制lai',
  redirectUri: 'http://localhost:3000/',
  responseType: 'token id_token',
  scope: 'openid profile manage:points',
});

class App extends Component {
  // ... constructor 定义

  componentDidMount() {
    const self = this;

    Auth0.handleAuthCallback();

    Auth0.subscribe((auth) => {
      console.log(auth);
    });

    // ... setInterval 
  }

  // ... trackMouse and render functions
}

// ... propTypes definition and export statement
复制代码

注意:你必须填写你自己的域和应用ID。这两个参数你可以在你的Dashboard里面复制过来。除此之外,如果你还想发布你的游戏,你需要把redirectUri的值也替换掉。

这个文件发生的改变很简单,下面是一个改变的列表:

  1. configure:你使用这个方法来配置了你的auth0-web
  2. handleAuthCallback:在componentDidMount生命周期中,你激活这个方法来获取用户信息。这个方法会发送请求从Auth0调取用户信息并存储在localStorage中。
  3. subscribe:这个方法用来判断用户是否已经验证了。值为布尔类型。

好了,你的游戏现在已经介入了Auth0 的单点登录了。现在运行你的游戏,你会看见登录按钮并在点击之后会跳转到Auth0的登录页面。

当你成功登陆,Auth0会回调你游戏的地址。你之前在代码里面写了console.log所以现在我们能看到登陆状态的任何改变。

创建排行榜组件

现在你已经配置了Auth0来管理你的用户安正,你需要做一个组件来展示当前用户的最高分数,你可以分解地创建两个组件:Leaderboard组件和Rank组件。因为优雅地展示出用户信息(比如UI高分,名字,位置和图片)并不是那么容易。不过也不是太难,只不过你要敲不少的代码。所以为了不让一份代码文件过于庞大笨拙,我们对它进行拆分。

你的游戏现在还没有任何玩家,为了实现功能,你需要制造一些假数据来构成你的排行榜。我们来修改你的Canvas组件。把其中的Login组件替换为Leaderboard组件。我们一会儿会把Login组件安装到Leaderboard组件中去。

Canvas.jsx

// ... other import statements
// replace Login with the following line
import Leaderboard from './Leaderboard';

const Canvas = (props) => {
  // ... const definitions
  const leaderboard = [
    { id: 'd4', maxScore: 82, name: 'Ado Kukic', picture: 'https://twitter.com/KukicAdo/profile_image', },
    { id: 'a1', maxScore: 235, name: 'Bruno Krebs', picture: 'https://twitter.com/brunoskrebs/profile_image', },
    { id: 'c3', maxScore: 99, name: 'Diego Poza', picture: 'https://twitter.com/diegopoza/profile_image', },
    { id: 'b2', maxScore: 129, name: 'Jeana Tahnk', picture: 'https://twitter.com/jeanatahnk/profile_image', },
    { id: 'e5', maxScore: 34, name: 'Jenny Obrien', picture: 'https://twitter.com/jenny_obrien/profile_image', },
    { id: 'f6', maxScore: 153, name: 'Kim Maida', picture: 'https://twitter.com/KimMaida/profile_image', },
    { id: 'g7', maxScore: 55, name: 'Luke Oliff', picture: 'https://twitter.com/mroliff/profile_image', },
    { id: 'h8', maxScore: 146, name: 'Sebastián Peyrott', picture: 'https://twitter.com/speyrott/profile_image', },
  ];
  return (
    <svg ...>
      // ... other elements

      { ! props.gameState.started &&
      <g>
        // ... StartGame and Title
        <Leaderboard currentPlayer={leaderboard[6]} authenticate={signIn} leaderboard={leaderboard} />
      </g>
      }

      // ... flyingObjects.map
    </svg>
  );
};

// ... propTypes definition and export statement
复制代码

在这个新版本中你定义了一个常量叫做learderboard。这是一个数组,每个元素是一个对象。每个对象包含着一个玩家的信息:id,最高分,名字和图片。然后,在svg标签里,你添加了Leaderboard组件并传入如下参数:

  • currentPlayer:这个参数定义了当前玩家是谁。现在暂时定义一个假的玩家数据,以看到效果。添加这个参数是让你的组件将当前玩家进行高亮。
  • authenticate:这根我们之前传给Login组建的参数是一样的。
  • leaderboard:这个是我们指定的假的玩家数据。

现在,你应该开始制作Leaderboard组件了。在src/components中创建Leaderboard.jsx

import React from 'react';
import PropTypes from 'prop-types';
import Login from './Login';
import Rank from "./Rank";

const Leaderboard = (props) => {
  const style = {
    fill: 'transparent',
    stroke: 'black',
    strokeDasharray: '15',
  };

  const leaderboardTitle = {
    fontFamily: '"Joti One", cursive',
    fontSize: 50,
    fill: '#88da85',
    cursor: 'default',
  };

  let leaderboard = props.leaderboard || [];
  leaderboard = leaderboard.sort((prev, next) => {
    if (prev.maxScore === next.maxScore) {
      return prev.name <= next.name ? 1 : -1;
    }
    return prev.maxScore < next.maxScore ? 1 : -1;
  }).map((member, index) => ({
    ...member,
    rank: index + 1,
    currentPlayer: member.id === props.currentPlayer.id,
  })).filter((member, index) => {
    if (index < 3 || member.id === props.currentPlayer.id) return member;
    return null;
  });

  return (
    <g>
      <text filter="url(#shadow)" style={leaderboardTitle} x="-150" y="-630">Leaderboard</text>
      <rect style={style} x="-350" y="-600" width="700" height="330" />
      {
        props.currentPlayer && leaderboard.map((player, idx) => {
          const position = {
            x: -100,
            y: -530 + (70 * idx)
          };
          return <Rank key={player.id} player={player} position={position}/>
        })
      }
      {
        ! props.currentPlayer && <Login authenticate={props.authenticate} />
      }
    </g>
  );
};

Leaderboard.propTypes = {
  currentPlayer: PropTypes.shape({
    id: PropTypes.string.isRequired,
    maxScore: PropTypes.number.isRequired,
    name: PropTypes.string.isRequired,
    picture: PropTypes.string.isRequired,
  }),
  authenticate: PropTypes.func.isRequired,
  leaderboard: PropTypes.arrayOf(PropTypes.shape({
    id: PropTypes.string.isRequired,
    maxScore: PropTypes.number.isRequired,
    name: PropTypes.string.isRequired,
    picture: PropTypes.string.isRequired,
    ranking: PropTypes.number,
  })),
};

Leaderboard.defaultProps = {
  currentPlayer: null,
  leaderboard: null,
};

export default Leaderboard;
复制代码

别害怕!这里的代码其实超级简单:

  1. 你定义了leaderboardTitle常量来设置Title的展示。
  2. 你定义了一个虚线边框透明的长方体来作为排行榜的容器。
  3. 你调用了sort方法来对props.leaderboard进行排序。之后高分在上低分在下。相同分则会按照名字顺序来排。
  4. 你调用了map方法对上一步的结果进行包装,对当前用户进行标记。你将会用这个标记来对当前用户进行高亮显示。
  5. 你调用了filter方法,对上一步的结果进行过滤,把前三名以外的玩家移除。不过,如果当前玩家不在前三名,就会在第四个位置显示而不会被移除。
  6. 最后,你简单地遍历数据进行渲染。

最后你要做的就是制作Rank.jsx组件了:

import React from 'react';
import PropTypes from 'prop-types';

const Rank = (props) => {
  const { x, y } = props.position;

  const rectId = 'rect' + props.player.rank;
  const clipId = 'clip' + props.player.rank;

  const pictureStyle = {
    height: 60,
    width: 60,
  };

  const textStyle = {
    fontFamily: '"Joti One", cursive',
    fontSize: 35,
    fill: '#e3e3e3',
    cursor: 'default',
  };

  if (props.player.currentPlayer) textStyle.fill = '#e9ea64';

  const pictureProperties = {
    style: pictureStyle,
    x: x - 140,
    y: y - 40,
    href: props.player.picture,
    clipPath: `url(#${clipId})`,
  };

  const frameProperties = {
    width: 55,
    height: 55,
    rx: 30,
    x: pictureProperties.x,
    y: pictureProperties.y,
  };

  return (
    <g>
      <defs>
        <rect id={rectId} {...frameProperties} />
        <clipPath id={clipId}>
          <use xlinkHref={'#' + rectId} />
        </clipPath>
      </defs>
      <use xlinkHref={'#' + rectId} strokeWidth="2" stroke="black" />
      <text filter="url(#shadow)" style={textStyle} x={x - 200} y={y}>{props.player.rank}º</text>
      <image {...pictureProperties} />
      <text filter="url(#shadow)" style={textStyle} x={x - 60} y={y}>{props.player.name}</text>
      <text filter="url(#shadow)" style={textStyle} x={x + 350} y={y}>{props.player.maxScore}</text>
    </g>
  );
};

Rank.propTypes = {
  player: PropTypes.shape({
    id: PropTypes.string.isRequired,
    maxScore: PropTypes.number.isRequired,
    name: PropTypes.string.isRequired,
    picture: PropTypes.string.isRequired,
    rank: PropTypes.number.isRequired,
    currentPlayer: PropTypes.bool.isRequired,
  }).isRequired,
  position: PropTypes.shape({
    x: PropTypes.number.isRequired,
    y: PropTypes.number.isRequired
  }).isRequired,
};

export default Rank;
复制代码

这段超长代码也没什么可怕的。不同的是,这个组件是一个clipPath元素和rect通过defs定义的圆形的组合元素,用来展示用户的图片。 现在,运行应用查看效果吧!

使用Socket.IO让排行榜实时显示

你已经让排行榜显示出来了,下一步做什么呢?我们可以利用Socket.IO来让排行榜实时显示数据。这可能会让你思考:创建一个实时的服务应该会很困难吧?不,不是的。使用Socket.IO,你可以瞬间完成这个功能。但是,在开始之前,你一定想要保护你的后端服务不被攻击,对吧?你需要创建一个Auth0 API来代表你的服务。

做起来很容易。打开Auth0 Dashboard 上面的 API 页面点击CreateAPI按钮,然后会蹦出来一个表单让你填三样东西:

  1. API的名字。你需要起一个有代表性的名字防止以后你忘了这个API是做什么的。
  2. API验证地址。先填这个吧:https://aliens-go-home.digituz.com.br
  3. 选择一个算法,RS256或者HS256。如果你感兴趣:两者区别

填写完成之后会重定向到QuickStart页面。现在点击Scopes标签并创建一个scope。名字就叫manage:points,描述就填写Read and Write MaxScore。这是为了让你更好的记住你的API是做什么的。写完scope你就可以回去继续编码了。

# 创建一个服务文件夹
mkdir server

# 进入
cd server

# 初始化服务的npm
npm init -y

# 安装依赖
npm i express jsonwebtoken jwks-rsa socket.io socketio-jwt

touch index.js

复制代码

编辑server/index.js

const app = require('express')();
const http = require('http').Server(app);
const io = require('socket.io')(http);
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

const client = jwksClient({
  jwksUri: 'https://YOUR_AUTH0_DOMAIN/.well-known/jwks.json'
});

const players = [
  { id: 'a1', maxScore: 235, name: 'Bruno Krebs', picture: 'https://twitter.com/brunoskrebs/profile_image', },
  { id: 'c3', maxScore: 99, name: 'Diego Poza', picture: 'https://twitter.com/diegopoza/profile_image', },
  { id: 'b2', maxScore: 129, name: 'Jeana Tahnk', picture: 'https://twitter.com/jeanatahnk/profile_image', },
  { id: 'f6', maxScore: 153, name: 'Kim Maida', picture: 'https://twitter.com/KimMaida/profile_image', },
  { id: 'e5', maxScore: 55, name: 'Luke Oliff', picture: 'https://twitter.com/mroliff/profile_image', },
  { id: 'd4', maxScore: 146, name: 'Sebastián Peyrott', picture: 'https://twitter.com/speyrott/profile_image', },
];

const verifyPlayer = (token, cb) => {
  const uncheckedToken = jwt.decode(token, {complete: true});
  const kid = uncheckedToken.header.kid;

  client.getSigningKey(kid, (err, key) => {
    const signingKey = key.publicKey || key.rsaPublicKey;

    jwt.verify(token, signingKey, cb);
  });
};

const newMaxScoreHandler = (payload) => {
  let foundPlayer = false;
  players.forEach((player) => {
    if (player.id === payload.id) {
      foundPlayer = true;
      player.maxScore = Math.max(player.maxScore, payload.maxScore);
    }
  });

  if (!foundPlayer) {
    players.push(payload);
  }

  io.emit('players', players);
};

io.on('connection', (socket) => {
  const { token } = socket.handshake.query;

  verifyPlayer(token, (err) => {
    if (err) socket.disconnect();
    io.emit('players', players);
  });

  socket.on('new-max-score', newMaxScoreHandler);
});

http.listen(3001, () => {
  console.log('listening on port 3001');
});
复制代码

开始了解这段代码之前,先用你自己的域替换掉YOUR_AUTH0_DOMAIN,就跟App.js一样。下面我们来了解一下这段代码:

  • ExpressSocket.io:这是一个简单的Socket.IOExpress框架的结合搭建的服务。如果你没有用过Socket.IO可以去看看官网基础教程,非常简单。
  • jwtjwksClient:在使用Auth0验证的时候,你的玩家通过别的渠道获得一个jwt(JSON Web Token)格式的access_token。因为你用了简单的RS256算法,你需要jwksClient这个包来获取正确的公钥来验证JWT。如果你感兴趣,可以到这里查看:auth0.com/docs/jwks
  • jwt.verify:在获得了公钥之后,你需要这个方法来解码jwt并进行验证。如果验证通过了,就会发送用户列表,否则就会关闭连接。
  • on('new-max-score', ...):不管什么时候你想要更新最大值,你需要在你的React中调用这个方法。

Socket.IO 和 React

创建了你的后台服务之后,是时候使用它来实现你的游戏了。最佳实践是安装the socket.io-client package

# 一定要进入你的React文件夹安装而不是你的server文件夹。

npm i socket.io-client
复制代码

之后你的游戏就会在用户登录之后连接到你的后台服务。如果用户不登录,就不会显示排行榜。你使用Redux来管理状态,你还需要lia两个action来保证你的Redux store的时效性。编辑./src/actions/index.js

export const LEADERBOARD_LOADED = 'LEADERBOARD_LOADED';
export const LOGGED_IN = 'LOGGED_IN';
// ... MOVE_OBJECTS and START_GAME ...

export const leaderboardLoaded = players => ({
  type: LEADERBOARD_LOADED,
  players,
});

export const loggedIn = player => ({
  type: LOGGED_IN,
  player,
});

// ... moveObjects and startGame ...
复制代码

这个版本定义了两个action分别对应两种情况:

  1. LOGGED_IN:当用户登陆了,会触发这个action去链接你的后台实时服务。
  2. LEADERBOARD_LOADED:当后台成功的获取了用户列表,这个action来更新React的store。

编辑./src/reducers/index.js来让你的React响应这些actions:

import {
  LEADERBOARD_LOADED, LOGGED_IN,
  MOVE_OBJECTS, START_GAME
} from '../actions';
// ... other import statements

const initialGameState = {
  // ... other game state properties
  currentPlayer: null,
  players: null,
};

// ... initialState definition

function reducer(state = initialState, action) {
  switch (action.type) {
    case LEADERBOARD_LOADED:
      return {
        ...state,
        players: action.players,
      };
    case LOGGED_IN:
      return {
        ...state,
        currentPlayer: action.player,
      };
    // ... MOVE_OBJECTS, START_GAME, and default cases
  }
}

export default reducer;
复制代码

现在用户登录和用户列表获取成功时都会自动更新store了。接下来,为了让你的游戏应用这些actions,编辑/src/containers/Game.js

// ... other import statements
import {
  leaderboardLoaded, loggedIn,
  moveObjects, startGame
} from '../actions/index';

const mapStateToProps = state => ({
  // ... angle and gameState
  currentPlayer: state.currentPlayer,
  players: state.players,
});

const mapDispatchToProps = dispatch => ({
  leaderboardLoaded: (players) => {
    dispatch(leaderboardLoaded(players));
  },
  loggedIn: (player) => {
    dispatch(loggedIn(player));
  },
  // ... moveObjects and startGame
});

// ... connect and export statement

复制代码

现在,可以开始链接后台服务了,编辑/src/App.js

// ... other import statements
import io from 'socket.io-client';

Auth0.configure({
  // ... other properties
  audience: 'https://aliens-go-home.digituz.com.br',
});

class App extends Component {
  // ... constructor

  componentDidMount() {
    const self = this;

    Auth0.handleAuthCallback();

    Auth0.subscribe((auth) => {
      if (!auth) return;

      const playerProfile = Auth0.getProfile();
      const currentPlayer = {
        id: playerProfile.sub,
        maxScore: 0,
        name: playerProfile.name,
        picture: playerProfile.picture,
      };

      this.props.loggedIn(currentPlayer);

      const socket = io('http://localhost:3001', {
        query: `token=${Auth0.getAccessToken()}`,
      });

      let emitted = false;
      socket.on('players', (players) => {
        this.props.leaderboardLoaded(players);

        if (emitted) return;
        socket.emit('new-max-score', {
          id: playerProfile.sub,
          maxScore: 120,
          name: playerProfile.name,
          picture: playerProfile.picture,
        });
        emitted = true;
        setTimeout(() => {
          socket.emit('new-max-score', {
            id: playerProfile.sub,
            maxScore: 222,
            name: playerProfile.name,
            picture: playerProfile.picture,
          });
        }, 5000);
      });
    });

    // ... setInterval and onresize
  }

  // ... trackMouse

  render() {
    return (
      <Canvas
        angle={this.props.angle}
        currentPlayer={this.props.currentPlayer}
        gameState={this.props.gameState}
        players={this.props.players}
        startGame={this.props.startGame}
        trackMouse={event => (this.trackMouse(event))}
      />
    );
  }
}

App.propTypes = {
  // ... other propTypes definitions
  currentPlayer: PropTypes.shape({
    id: PropTypes.string.isRequired,
    maxScore: PropTypes.number.isRequired,
    name: PropTypes.string.isRequired,
    picture: PropTypes.string.isRequired,
  }),
  leaderboardLoaded: PropTypes.func.isRequired,
  loggedIn: PropTypes.func.isRequired,
  players: PropTypes.arrayOf(PropTypes.shape({
    id: PropTypes.string.isRequired,
    maxScore: PropTypes.number.isRequired,
    name: PropTypes.string.isRequired,
    picture: PropTypes.string.isRequired,
  })),
};

App.defaultProps = {
  currentPlayer: null,
  players: null,
};

export default App;
复制代码

在上面的代码里你做了这些事:

  1. 配置了Auth0模块的audience属性。
  2. 获取当前用户的信息(Auth0.getProfile()),创建当前用户的常量并更新了storethis.props.loggedIn(...)
  3. 使用玩家的access_token链接到后端服务:io('http://localhost:3001', ...)
  4. 监听你的后台服务触发的玩家事件来更新storethis.props.leaderboardLoaded(...)

你的游戏还没有完成,你的玩家现在还不能击落飞行物。你加入了一些临时代码来模拟new-max-score事件。首先,你模拟了一个maxScore是120,这让玩家拍到了第五。然后5秒钟(setTimeout(..., 5000))之后你有设置maxScore为222。让登陆的玩家排到了第二名。

除了这些修改,你还要传给画布组件一些新的属性:currentPlayerplayers。编辑/src/components/Canvas.jsx

// ... import statements

const Canvas = (props) => {
  // ... gameHeight and viewBox constants

  // REMOVE the leaderboard constant !!!!

  return (
    <svg ...>
      // ... other elements

      { ! props.gameState.started &&
      <g>
        // ... StartGame and Title
        <Leaderboard currentPlayer={props.currentPlayer} authenticate={signIn} leaderboard={props.players} />
      </g>
      }

      // ... flyingObjects.map
    </svg>
  );
};

Canvas.propTypes = {
  // ... other propTypes definitions
  currentPlayer: PropTypes.shape({
    id: PropTypes.string.isRequired,
    maxScore: PropTypes.number.isRequired,
    name: PropTypes.string.isRequired,
    picture: PropTypes.string.isRequired,
  }),
  players: PropTypes.arrayOf(PropTypes.shape({
    id: PropTypes.string.isRequired,
    maxScore: PropTypes.number.isRequired,
    name: PropTypes.string.isRequired,
    picture: PropTypes.string.isRequired,
  })),
};

Canvas.defaultProps = {
  currentPlayer: null,
  players: null,
};

export default Canvas;
复制代码

在这个文件中,你做出了如下修改:

  1. 移除了之前直接硬编码的leaderboard。现在是从后台服务获取了。
  2. 更新了<Leaderboard/>的代码,现在通过props获取了。
  3. 增强了propTypes类型定义。

好了!你已经完成了排行榜部分,通过以下命令来启动你的应用吧:

# move to the real-time service directory
cd server

# run it on the background
node index.js &

# move back to your game
cd ..

# start the React development server
npm start
复制代码

完成缺失的部分

已经快完成了,但是还少一些东西:

  • 射击的功能。
  • 碰撞检测。
  • 更新生命值信息和当前分数。
  • 更新排行榜。

那么,在接下来的几个段落,将会告诉你如何完成剩下的部分。

射击

你需要添加一个点击事件来让你的玩家可以发射加农炮弹。点击的时候出发action来给store添加一个炮弹。这个炮弹的轨迹将由moveObjects来维护。 编辑/src/actions/index.js来实现这个功能:

// ... other string constants

export const SHOOT = 'SHOOT';

// ... other function constants

export const shoot = (mousePosition) => ({
  type: SHOOT,
  mousePosition,
});
复制代码

然后你需要一个reducer来处理这个action。编辑/src/reducers/index.js

import {
  LEADERBOARD_LOADED, LOGGED_IN,
  MOVE_OBJECTS, SHOOT, START_GAME
} from '../actions';
// ... other import statements
import shoot from './shoot';

const initialGameState = {
  // ... other properties
  cannonBalls: [],
};

// ... initialState definition

function reducer(state = initialState, action) {
  switch (action.type) {
    // other case statements
    case SHOOT:
      return shoot(state, action);
    // ... default statement
  }
}
复制代码

这里引入了shoot方法,但是我们还没有创建这个文件。在同一目录下创建shoot.js

import { calculateAngle } from '../utils/formulas';

function shoot(state, action) {
  if (!state.gameState.started) return state;

  const { cannonBalls } = state.gameState;

  if (cannonBalls.length === 2) return state;

  const { x, y } = action.mousePosition;

  const angle = calculateAngle(0, 0, x, y);

  const id = (new Date()).getTime();
  const cannonBall = {
    position: { x: 0, y: 0 },
    angle,
    id,
  };

  return {
    ...state,
    gameState: {
      ...state.gameState,
      cannonBalls: [...cannonBalls, cannonBall],
    }
  };
}

export default shoot;
复制代码

这个函数在最开始检查了游戏的开始状态。如果游戏没有开始,简单地返回当前的state。如果开始了,它会检查是否存已经在两个炮弹了。你限制了炮弹的个数来让游戏稍微难一点儿。如果现存炮弹少于2个,这个函数会计算一个炮弹轨迹并发射一枚新的炮弹,如果不少于两个则不能发射。最后函数创建了一个新的对象代表新创建的炮弹并返回一个新的store。

在定义了这个函数之后,你还需要更新Game.js的代码来向App组件提供action。更新/src/containers/Game.js文件:

// ... other import statements
import {
  leaderboardLoaded, loggedIn,
  moveObjects, startGame, shoot
} from '../actions/index';

// ... mapStateToProps

const mapDispatchToProps = dispatch => ({
  // ... other functions
  shoot: (mousePosition) => {
    dispatch(shoot(mousePosition))
  },
});

// ... connect and export
复制代码

同样的你还需要编辑/src/App.js

// ... import statements and Auth0.configure

class App extends Component {
  constructor(props) {
    super(props);
    this.shoot = this.shoot.bind(this);
  }

  // ... componentDidMount and trackMouse definition

  shoot() {
    this.props.shoot(this.canvasMousePosition);
  }

  render() {
    return (
      <Canvas
        // other props
        shoot={this.shoot}
      />
    );
  }
}

App.propTypes = {
  // ... other propTypes
  shoot: PropTypes.func.isRequired,
};

// ... defaultProps and export statements
复制代码

这里跟你看到的一样,你在App里定义了一个新的方法:使用canvasMousePosition作为参数来调用shootdispatcher。然后你把这个新的方法传给了画布组件。所以你还要在画布组件里添加点击事件来触发这一系列的流程。

// ... other import statements
import CannonBall from './CannonBall';

const Canvas = (props) => {
  // ... gameHeight and viewBox constant

  return (
    <svg
      // ... other properties
      onClick={props.shoot}
    >
      // ... defs, Sky and Ground elements

      {props.gameState.cannonBalls.map(cannonBall => (
        <CannonBall
          key={cannonBall.id}
          position={cannonBall.position}
        />
      ))}

      // ... CannonPipe, CannonBase, CurrentScore, etc
    </svg>
  );
};

Canvas.propTypes = {
  // ... other props
  shoot: PropTypes.func.isRequired,
};

// ... defaultProps and export statement
复制代码

注意,一定要在加农炮组件之前添加加农炮组件,防止炮弹盖住大炮。

这些修改足矣让你在初始位置(0,0)生成新的炮弹,并定义了他们的轨迹angle。现在问题是,他们还不会动。为了让他们移动起来,你需要加点儿公式到/src/utils/formulas.js

const degreesToRadian = degrees => ((degrees * Math.PI) / 180);

export const calculateNextPosition = (x, y, angle, divisor = 300) => {
  const realAngle = (angle * -1) + 90;
  const stepsX = radiansToDegrees(Math.cos(degreesToRadian(realAngle))) / divisor;
  const stepsY = radiansToDegrees(Math.sin(degreesToRadian(realAngle))) / divisor;
  return {
    x: x +stepsX,
    y: y - stepsY,
  }
};
复制代码

注意:想知道这个公式如何工作的?看这里

你将要在moveCannonBalls.js中使用calculateNextPosition方法。在/src/reducers/创建这个js文件:

import { calculateNextPosition } from '../utils/formulas';

const moveBalls = cannonBalls => (
  cannonBalls
    .filter(cannonBall => (
      cannonBall.position.y > -800 && cannonBall.position.x > -500 && cannonBall.position.x < 500
    ))
    .map((cannonBall) => {
      const { x, y } = cannonBall.position;
      const { angle } = cannonBall;
      return {
        ...cannonBall,
        position: calculateNextPosition(x, y, angle, 5),
      };
    })
);

export default moveBalls;
复制代码

在这个文件的代码里,你做了件重要的事情。你将可用区域外的炮弹都移除掉了。接下来要做的就是重构/src/reducers/moveObjects.js来使用这个函数:

// ... other import statements
import moveBalls from './moveCannonBalls';

function moveObjects(state, action) {
  if (!state.gameState.started) return state;

  let cannonBalls = moveBalls(state.gameState.cannonBalls);

  // ... mousePosition, createFlyingObjects, filter, etc

  return {
    ...newState,
    gameState: {
      ...newState.gameState,
      flyingObjects,
      cannonBalls,
    },
    angle,
  };
}

export default moveObjects;
复制代码

在这个新版本中,你简单地增强了这个moveObjects来保证能创建新的炮弹。然后你用这个函数更新了存储炮弹的数组。现在你可以运行你的游戏看一下,已经可以发射炮弹了!

碰撞检测

现在你的玩家已经可以发射炮弹了,下一步我们来做碰撞检测。通过这个算法,你可以把碰到一起的炮弹和飞行物移除。这也将支持你完成下面的任务:涨分。

实现这个功能的一个比较好的战略是,把炮弹和飞行物都想象成长方形。把他们当做长方形来处理会让事情变得很容易。在这个游戏里你不需要太高的精度。抱着这样的想法,我们在/src/utils/formulas.js中添加这样一个函数:

// ... other functions

export const checkCollision = (rectA, rectB) => (
  rectA.x1 < rectB.x2 && rectA.x2 > rectB.x1 &&
  rectA.y1 < rectB.y2 && rectA.y2 > rectB.y1
);
复制代码

你也看到了,把他们当做长方形处理,碰撞检测的代码条件很少也很简单。下面在/src/reducers中创建一个checkCollisions.js来使用这个函数:

import { checkCollision } from '../utils/formulas';
import { gameHeight } from '../utils/constants';

const checkCollisions = (cannonBalls, flyingDiscs) => {
  const objectsDestroyed = [];
  flyingDiscs.forEach((flyingDisc) => {
    const currentLifeTime = (new Date()).getTime() - flyingDisc.createdAt;
    const calculatedPosition = {
      x: flyingDisc.position.x,
      y: flyingDisc.position.y + ((currentLifeTime / 4000) * gameHeight),
    };
    const rectA = {
      x1: calculatedPosition.x - 40,
      y1: calculatedPosition.y - 10,
      x2: calculatedPosition.x + 40,
      y2: calculatedPosition.y + 10,
    };
    cannonBalls.forEach((cannonBall) => {
      const rectB = {
        x1: cannonBall.position.x - 8,
        y1: cannonBall.position.y - 8,
        x2: cannonBall.position.x + 8,
        y2: cannonBall.position.y + 8,
      };
      if (checkCollision(rectA, rectB)) {
        objectsDestroyed.push({
          cannonBallId: cannonBall.id,
          flyingDiscId: flyingDisc.id,
        });
      }
    });
  });
  return objectsDestroyed;
};

export default checkCollisions;
复制代码

这个文件做了下面几件事情:

  1. 创建了一个数组叫objectsDestroyed来存储已经被销毁的对象。
  2. 遍历flyingDiscs对象,给每个飞行物创建一个矩形检测区域。既然你是用css来移动他们的,你就应该通过currentLifeTime来计算他们Y轴上的位置。X轴不会变。
  3. 遍历cannonBalls对象,给每个炮弹创建矩形检测区域。
  4. 调用checkCollision对3和4进行计算。碰撞的消除。并把消除的对象添加进objectsDestroyed

最后你需要更新moveObjects.js的代码来使用这个函数:

// ... import statements

import checkCollisions from './checkCollisions';

function moveObjects(state, action) {
  // ... other statements and definitions

  // the only change in the following three lines is that it cannot
  // be a const anymore, it must be defined with let
  let flyingObjects = newState.gameState.flyingObjects.filter(object => (
    (now - object.createdAt) < 4000
  ));

  // ... { x, y } constants and angle constant

  const objectsDestroyed = checkCollisions(cannonBalls, flyingObjects);
  const cannonBallsDestroyed = objectsDestroyed.map(object => (object.cannonBallId));
  const flyingDiscsDestroyed = objectsDestroyed.map(object => (object.flyingDiscId));

  cannonBalls = cannonBalls.filter(cannonBall => (cannonBallsDestroyed.indexOf(cannonBall.id)));
  flyingObjects = flyingObjects.filter(flyingDisc => (flyingDiscsDestroyed.indexOf(flyingDisc.id)));

  return {
    ...newState,
    gameState: {
      ...newState.gameState,
      flyingObjects,
      cannonBalls,
    },
    angle,
  };
}

export default moveObjects;
复制代码

在这里你使用checkCollisions的结果从数组中移除了炮弹和飞行物。并且会从gameState中移除这两个碰撞的元素。你可以在你的浏览器里试一下。

更新生命值和当前分数

每当一个飞行物调到地上,你必须要减掉玩家的声明值。并且你需要在声明值为0时结束游戏。你需要更改两个文件来实现,先编辑/src/reducers/moveObject.js

import { calculateAngle } from '../utils/formulas';
import createFlyingObjects from './createFlyingObjects';
import moveBalls from './moveCannonBalls';
import checkCollisions from './checkCollisions';

function moveObjects(state, action) {
  // ... code until newState.gameState.flyingObjects.filter

  const lostLife = state.gameState.flyingObjects.length > flyingObjects.length;
  let lives = state.gameState.lives;
  if (lostLife) {
    lives--;
  }

  const started = lives > 0;
  if (!started) {
    flyingObjects = [];
    cannonBalls = [];
    lives = 3;
  }

  // ... x, y, angle, objectsDestroyed, etc ...

  return {
    ...newState,
    gameState: {
      ...newState.gameState,
      flyingObjects,
      cannonBalls: [...cannonBalls],
      lives,
      started,
    },
    angle,
  };
}

export default moveObjects;
复制代码

这次的修改通过用当前flyingObjects的长度与原来的长度进行对比,来判断玩家是否会丢失生命值。你把这个代码紧接着放在了创建球的代码后面,所以他能准确地监控。

现在你需要更新Canvas.jsx来使它生效:

// ... other import statements
import Heart from './Heart';

const Canvas = (props) => {
  // ... gameHeight and viewBox constants

  const lives = [];
  for (let i = 0; i < props.gameState.lives; i++) {
    const heartPosition = {
      x: -180 - (i * 70),
      y: 35
    };
    lives.push(<Heart key={i} position={heartPosition}/>);
  }

  return (
    <svg ...>
      // ... all other elements

      {lives}
    </svg>
  );
};

// ... propTypes, defaultProps, and export statements
复制代码

改完这些东西,你基本就快要完成了。现在如果有飞行物落地,就会减少生命值,生命值为0就会结束游戏。现在,还差一件事情,就是增加分数。其实也很简单,你需要像这样编辑/src/reducers/moveObjects.js

// ... import statements

function moveObjects(state, action) {
  // ... everything else

  const kills = state.gameState.kills + flyingDiscsDestroyed.length;

  return {
    // ...newState,
    gameState: {
      // ... other props
      kills,
    },
    // ... angle,
  };
}

export default moveObjects;
复制代码

然后修改画布组件。把CurrentScore中的硬编码15,给替换掉。

更新排行榜

好消息!这是最后一件事了!编写完更新排行榜的功能,你就完成啦。

首先你需要编辑/server/index.js来查看玩家列表。你肯定不想让你的玩家都是假的。所以,先把模拟的玩家数据都删除掉吧:

const players = [];
复制代码

然后你需要重构App.js

// ... import statetments

// ... Auth0.configure

class App extends Component {
  constructor(props) {
    // ... super and this.shoot.bind(this)
    this.socket = null;
    this.currentPlayer = null;
  }

  // replace the whole content of the componentDidMount method
  componentDidMount() {
    const self = this;

    Auth0.handleAuthCallback();

    Auth0.subscribe((auth) => {
      if (!auth) return;

      self.playerProfile = Auth0.getProfile();
      self.currentPlayer = {
        id: self.playerProfile.sub,
        maxScore: 0,
        name: self.playerProfile.name,
        picture: self.playerProfile.picture,
      };

      this.props.loggedIn(self.currentPlayer);

      self.socket = io('http://localhost:3001', {
        query: `token=${Auth0.getAccessToken()}`,
      });

      self.socket.on('players', (players) => {
        this.props.leaderboardLoaded(players);
        players.forEach((player) => {
          if (player.id === self.currentPlayer.id) {
            self.currentPlayer.maxScore = player.maxScore;
          }
        });
      });
    });

    setInterval(() => {
      self.props.moveObjects(self.canvasMousePosition);
    }, 10);

    window.onresize = () => {
      const cnv = document.getElementById('aliens-go-home-canvas');
      cnv.style.width = `${window.innerWidth}px`;
      cnv.style.height = `${window.innerHeight}px`;
    };
    window.onresize();
  }

  componentWillReceiveProps(nextProps) {
    if (!nextProps.gameState.started && this.props.gameState.started) {
      if (this.currentPlayer.maxScore < this.props.gameState.kills) {
        this.socket.emit('new-max-score', {
          ...this.currentPlayer,
          maxScore: this.props.gameState.kills,
        });
      }
    }
  }

  // ... trackMouse, shoot, and render method
}

// ... propTypes, defaultProps, and export statement
复制代码

下面是本次修改的总结:

  1. 你定义了两个新的属性叫做socketcurrentPlayer。你可以在不同的方法里使用它们。
  2. 你一出了为了模拟new-max-score创建的分数们。
  3. 你遍历了从Socket.IO获得的玩家列表来渲染组件。
  4. 你定义了componentWillReceiveProps生命周期函数来检查玩家是否达到了最高分,如果达到了,就更新最高分的记录。

好了!完成了!试试你的游戏吧!

node ./server/index &

npm start
复制代码

这时你创建两个不同的账户,就会有一个真实的排行榜了。

总结

这一系列下来,你使用牛逼的技术完成了这个牛逼的游戏。你用React控制游戏所有的使用SVG创建的元素。你还用CSS动画让你的游戏运动起来。哦对了,你还用了SocketIO来实时更新你的排行榜。还学会了如何接入Auth0的用户验证系统。

略略略!你用了很长时间来做这件事并且学到了很多东西。是时候放松一下,开始玩你的游戏了!

文章分类
前端