Skip to content

Latest commit

 

History

History
1249 lines (953 loc) · 42.9 KB

File metadata and controls

1249 lines (953 loc) · 42.9 KB

七、游戏

应用商店中大多数最成功的应用都是游戏。事实证明,它们确实很受欢迎,因为移动用户往往在上下班、候车室、旅行甚至在家休息时玩各种游戏。事实上,手机用户更倾向于为游戏付费,而不是为市场上任何其他类型的应用付费,因为大多数时候,感知价值更高。

现代游戏通常内置于强大的游戏引擎中,如 Unity 或 Unreal,因为它们提供了大量的工具和框架来处理精灵、动画或物理。但现实情况是,伟大的游戏也可以在 React Native 中构建,因为它具有本地功能。此外,React Native 还将许多 web 和移动应用程序员引入游戏开发,因为它为他们提供了熟悉和直观的界面。当然,游戏开发中有一些概念需要理解,以便在构建游戏时充分利用库。精灵、滴答声或碰撞等概念都是小障碍,非游戏开发人员可能需要在构建游戏之前克服这些障碍

该游戏将同时为 iOS 和 Android 构建,并将使用数量有限的外部库。选择状态管理库 Redux 来帮助计算每个帧上每个精灵的位置

我们将使用一些自定义精灵,并添加一个声音效果,以注意每次分数增加。构建游戏时的一个主要挑战是确保精灵得到响应性渲染,因此不同的设备将以相同的比例显示游戏,在不同的屏幕大小上提供相同的游戏体验。

此游戏将设计为仅在纵向模式下玩。

概述

我们将在本章中构建的游戏具有简单的机制:

  • 目的是帮助鹦鹉在洞穴的岩石之间飞翔
  • 轻敲屏幕会使鹦鹉飞得更高
  • 重力会把鹦鹉拉向地面
  • 鹦鹉与岩石或地面之间的任何碰撞都将导致游戏结束
  • 每次鹦鹉飞越一组岩石时,分数都会增加

这种游戏非常适合使用 React Native 构建,因为它不需要复杂的动画或物理功能。我们需要确保的是,我们在正确的时间移动屏幕上的每个精灵(图形组件),以创建连续动画的感觉。

让我们来看看我们游戏的初始屏幕:

此屏幕显示徽标和有关如何开始游戏的说明。在这种情况下,一个简单的点击将启动游戏机制,使鹦鹉在每次点击时向前飞并向上飞。

玩家必须帮助我们的鹦鹉飞越岩石。每次通过一组岩石,玩家将得到一分。

更困难的是,岩石的高度会有所不同,迫使鹦鹉飞得更高或更低才能穿过岩石。如果鹦鹉与岩石或地面相撞,游戏将停止,最终分数将显示给用户:

此时,用户将能够通过再次点击屏幕重新启动游戏

为了使其更美观、更易于播放,轻敲可以在屏幕上的任何位置进行,根据用户所在的屏幕产生不同的效果:

  • 在初始屏幕上点击将启动游戏
  • 在游戏中轻敲会导致鹦鹉飞得更高
  • 在游戏屏幕上点击将重新启动游戏并重置分数

正如可以观察到的,这将是一个非常简单的游戏,但由于这一点,很容易扩展和乐趣发挥。在构建此类应用时,一个重要的方面是使用一组漂亮的图形进行计数。就这一点而言,我们将从多个游戏资产市场中的一个下载我们的资产,该市场可以在网上找到(大多数游戏资产成本很低,但偶尔可以找到免费资产)。

这个游戏的技术挑战更多地在于精灵如何随时间移动,而不是维持复杂的状态。尽管如此,我们仍将使用 Redux 来保持和更新应用的状态,因为它是一个性能卓越且众所周知的解决方案。除了重温 Redux,我们将在本章中回顾以下主题:

  • 处理动画精灵
  • 播放音效
  • 探测碰撞的精灵
  • 不同屏幕分辨率下的绝对定位

精灵

精灵是游戏使用的图形,通常分组为一个或多个图像。许多游戏引擎包括以方便的方式分割和管理这些图形的工具,但 React Native 中的情况并非如此。由于它是用一种不同类型的应用设计的,在处理精灵的任务中有几个库支持 React Native,但我们的游戏将非常简单,不需要任何这些库,因此我们将在每个图像中存储一个图形,并将它们分别加载到应用中。

在开始构建游戏之前,让我们先了解一下将加载的图形,因为它们将是整个应用的构建块。

数字

在我们的游戏中,我们不会使用<Text/>组件来显示分数,而是使用精灵来获得更具吸引力的外观。以下是我们将用于表示用户分数的图像:

如上所述,由于 React Native 缺乏精灵分割功能,所有这些图形将存储在单独的图像中(命名为0.png9.png

出身背景

我们需要一个大背景,以确保它将适合所有的屏幕大小。在本章中,我们将使用此精灵作为静态图形,尽管它可以轻松设置动画以创建良好的视差效果:

从这个背景,我们将采取一块地面动画。

地面将在循环中设置动画,以创建恒定的速度感。此图像的大小需要大于我们希望支持的最大屏幕分辨率,因为它应该从屏幕的一侧移到另一侧。始终会依次显示两幅地面图像,以确保动画期间屏幕上至少显示其中一幅:

岩石

移动的岩石是我们鹦鹉需要通过的障碍。顶部和底部各有一个,两个都将以与地面相同的速度设置动画。每对岩石的高度会有所不同,但它们之间始终保持相同的间隙大小:

在我们的images文件夹中,我们将有rock-up.pngrock-down.png代表每个精灵。

鹦鹉

我们将为主角使用两个不同的图像,以便在用户点击屏幕时创建动画:

当鹦鹉向下移动时,将显示第一个图像:

每当用户按下屏幕向上移动鹦鹉时,将显示第二个图像。图像将被命名为parrot1.pngparrot2.png

主屏幕

对于主屏幕,我们将显示两幅图像:一个徽标和一些关于如何开始游戏的说明。让我们来看看它们:

开始游戏的说明仅指出轻敲将启动游戏:

屏幕上的游戏

当鹦鹉碰到岩石或地面时,游戏结束。然后,是时候显示游戏结束标志和重置按钮以重新开始游戏:

虽然可以触摸整个屏幕以重新启动游戏,但我们将包括一个按钮,让用户知道点击将导致游戏重新启动:

此图像将存储为reset.png

这是我们将在游戏中使用的图像的完整列表:

现在,我们知道了我们将在游戏中使用的图像列表。让我们来看看整个文件夹结构。

设置文件夹结构

让我们使用 React-Native 的 CLI 初始化 React-Native 项目。该项目将命名为birdGame,并可用于 iOS 和 Android 设备:

react-native init --version="0.46.4" birdGame

由于这是一个简单的游戏,我们只需要一个屏幕,我们将根据游戏的状态定位所有精灵的移动、显示或隐藏位置,这将由 Redux 管理。因此,我们的文件夹结构将符合标准 Redux 应用:

actions文件夹将只包含一个文件,因为在本游戏*(start、*tickbounce中只可能发生三个动作)。还有一个sounds文件夹,用于存储每次鹦鹉经过一对岩石时都会播放的音效:

对于每个精灵,我们将创建一个组件,以便可以轻松地移动、显示或隐藏它:

同样,只需要一个减速机来处理我们的所有操作。我们还将创建两个帮助文件:

  • constants.js:我们将在这里存储辅助变量,用于划分玩游戏的设备的屏幕高度和宽度
  • sprites.js:存储所有函数,这些函数将计算精灵在每个帧中的位置,以创建所需的动画

main.js将作为 iOS 和 Android 的入口点,并负责初始化 Redux:

其余文件由 React Native 的 CLI 生成。

现在让我们回顾一下在项目中设置依赖项所需的package.json文件:

/*** package.json ***/

{
  "name": "birdGame",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "start": "node node_modules/react-native/local-cli/cli.js start",
    "test": "jest"
  },
  "dependencies": {
    "react": "16.0.0-alpha.12",
    "react-native": "0.46.4",
    "react-native-sound": "^0.10.3",
    "react-redux": "^4.4.5",
    "redux": "^3.5.2"
  },
  "devDependencies": {
    "babel-jest": "20.0.3",
    "babel-preset-react-native": "2.1.0",
    "jest": "20.0.4",
    "react-test-renderer": "16.0.0-alpha.12"
  },
  "jest": {
    "preset": "react-native"
  }
}

除了 Redux 库,我们还将导入react-native-sound,它将负责在我们的游戏中播放任何声音。

运行npm install后,我们将准备好应用开始编码。与之前的应用一样,我们的消息应用的入口点在 iOS 的index.ios.js和 Android 的index.android.js中都是相同的代码,但都将初始化逻辑委托给src/main.js

/*** index.ios.js and index.android.js ***/ 

import { AppRegistry } from 'react-native';
import App from './src/main';

AppRegistry.registerComponent('birdGame', () => App);

src/main.js负责初始化 Redux,并将GameContainer设置为我们 app 中的根组件:

/*** src/main.js ***/

import React from "react";
import { createStore, combineReducers } from "redux";
import { Provider } from "react-redux";

import gameReducer from "./reducers/game";
import GameContainer from "./components/GameContainer";

let store = createStore(combineReducers({ gameReducer }));

export default class App extends React.Component {
  render() {
    return (
      <Provider store={store}>
        <GameContainer />
      </Provider>
    );
  }
}

我们在应用中使用GameContainer作为组件树的根。作为常规的 Redux 应用,<Provider />组件负责向所有需要读取或修改应用状态的组件提供存储。

游戏容器

GameContainer负责在用户点击屏幕后启动游戏。它将使用requestAnimationFrame()——在 React Native 中实现的自定义计时器之一来完成此操作

requestAnimationFrame()setTimeout()类似,但前者会在所有画面都冲洗干净后触发,而后者会尽快触发(iPhone 5S 上每秒超过 1000 次);因此,requestAnimationFrame()更适合动画游戏,因为它只处理帧。

与大多数动画游戏一样,我们需要创建一个循环,通过计算每个元素在每个帧上的下一个位置来为屏幕中的精灵设置动画。此循环将由我们的GameContainer中名为nextFrame()的函数创建:

nextFrame() {
    if (this.props.gameOver) return;
    var elapsedTime = new Date() - this.time;
    this.time = new Date();
    this.props.tick(elapsedTime);
    this.animationFrameId = 
      requestAnimationFrame(this.nextFrame.bind(this));
}

如果属性gameOver设置为true,此功能将中止。否则,它将触发动作tick()(根据经过的时间计算精灵应如何移动到下一帧),并最终通过requestAnimationFrame()调用自身。这将在游戏中保持循环以设置移动精灵的动画。

当然,这个nextFrame()应该是第一次在开始时调用,所以我们也会在GameContainer里面创建一个start()函数来启动游戏:

start() {
    cancelAnimationFrame(this.animationFrameId);
    this.props.start();
    this.props.bounce();
    this.time = new Date();
    this.setState({ gameOver: false });
    this.animationFrameId = 
      requestAnimationFrame(this.nextFrame.bind(this));
}

start函数通过调用cancelAnimationFrame()确保没有动画启动。这将防止在用户重置游戏时执行任何双重动画。

然后,这些函数触发start()动作,该动作只会在商店中设置一个标志来通知游戏已经开始。

我们希望通过向上移动鹦鹉来启动游戏,这样用户就有时间做出反应。为此,我们也称之为bounce()行动。

最后,我们通过将已知的nextFrame()函数作为requestAnimationFrame()的回调来启动动画循环。

我们也来回顾一下我们将用于此容器的render()方法:

render() {
    const {
      rockUp,
      rockDown,
      ground,
      ground2,
      parrot,
      isStarted,
      gameOver,
      bounce,
      score
    } = this.props;

    return (
      <TouchableOpacity
        onPress={
 !isStarted || gameOver ? this.start.bind(this) : 
            bounce.bind(this)
 }
        style={styles.screen}
        activeOpacity={1}
      >
        <Image
          source={require("../img/bg.png")}
          style={[styles.screen, styles.image]}
        />
        <RockUp
          x={rockUp.position.x * W} //W is a responsiveness factor 
                                    //explained in the 'constants' section
          y={rockUp.position.y}
          height={rockUp.size.height}
          width={rockUp.size.width}
        />
        <Ground
          x={ground.position.x * W}
          y={ground.position.y}
          height={ground.size.height}
          width={ground.size.width}
        />
        <Ground
          x={ground2.position.x * W}
          y={ground2.position.y}
          height={ground2.size.height}
          width={ground2.size.width}
        />
        <RockDown
          x={rockDown.position.x * W}
          y={rockDown.position.y * H} //H is a responsiveness factor  
                                      //explained in the 'constants' 
                                      //section
          height={rockDown.size.height}
          width={rockDown.size.width}
        />
        <Parrot
          x={parrot.position.x * W}
          y={parrot.position.y * H}
          height={parrot.size.height}
          width={parrot.size.width}
        />
        <Score score={score} />
        {!isStarted && <Start />}
        {gameOver && <GameOver />}
        {gameOver && isStarted && <StartAgain />}
      </TouchableOpacity>
    );
  }

它可能很长,但实际上,它是一个简单的屏幕上所有可见元素的定位,同时将它们包装在一个<TouchableOpacity />组件中,以捕获用户在屏幕的哪个部分点击。当用户点击屏幕时,该<TouchableOpacity />组件实际上没有向用户发送任何反馈(我们通过将activeOpacity={1}作为道具传递来禁用它),因为该反馈已经由鹦鹉在每次点击时弹跳提供

We could have used React Native's <TouchableWithoutFeedback /> for this matter, but it has several limitations which would have harmed our performance.

提供的onPress属性只定义了当用户点击屏幕时应用应该做什么:

  • 如果游戏处于活动状态,它将反弹鹦鹉精灵
  • 如果用户在游戏屏幕上,它将通过调用start()操作重新启动游戏

render()方法中的所有其他孩子都是我们游戏中的图形元素,指定每个孩子的位置和大小。注意以下几点也很重要:

  • 有两个<Ground />组件,因为我们需要在x轴上连续设置动画。它们将一个接一个地水平放置,以使它们一起动画化,因此当第一个<Ground />组件的末端显示在屏幕上时,第二个组件的开始将跟随创建连续体的感觉。
  • 背景不包含在任何自定义组件中,但包含在<Image />中。这是因为它不需要任何作为静态元素的特殊逻辑。
  • 某些位置乘以因子变量(WH。我们将在常量部分更深入地了解这些变量。在这一点上,我们只需要知道它们是帮助元素绝对定位的变量,考虑到所有屏幕尺寸。

现在,让我们将所有这些功能放在一起,以构建我们的<GameContainer />

/*** src/components/GameContainer.js ***/

import React, { Component } from "react";
import { connect } from "react-redux";
import { bindActionCreators } from "redux";
import { TouchableOpacity, Image, StyleSheet } from "react-native";

import * as Actions from "../actions";
import { W, H } from "../constants";
import Parrot from "./Parrot";
import Ground from "./Ground";
import RockUp from "./RockUp";
import RockDown from "./RockDown";
import Score from "./Score";
import Start from "./Start";
import StartAgain from "./StartAgain";
import GameOver from "./GameOver";

class Game extends Component {
  constructor() {
    super();
    this.animationFrameId = null;
    this.time = new Date();
  }

  nextFrame() {
     ...
  }

  start() {
     ...
  }

  componentWillUpdate(nextProps, nextState) {
    if (nextProps.gameOver) {
      this.setState({ gameOver: true });
      cancelAnimationFrame(this.animationFrameId);
    }
  }

  shouldComponentUpdate(nextProps, nextState) {
    return !nextState.gameOver;
  }

  render() {

     ...

  }
}

const styles = StyleSheet.create({
  screen: {
    flex: 1,
    alignSelf: "stretch",
    width: null
  },
  image: {
    resizeMode: "cover"
  }
});

function mapStateToProps(state) {
  const sprites = state.gameReducer.sprites;
  return {
    parrot: sprites[0],
 rockUp: sprites[1],
 rockDown: sprites[2],
 gap: sprites[3],
 ground: sprites[4],
 ground2: sprites[5],
 score: state.gameReducer.score,
 gameOver: state.gameReducer.gameOver,
 isStarted: state.gameReducer.isStarted
  };
}
function mapStateActionsToProps(dispatch) {
  return bindActionCreators(Actions, dispatch);
}

export default connect(mapStateToProps, mapStateActionsToProps)(Game);

我们在此组件中又添加了三种 ES6 和 React 生命周期方法:

  • super():构造器将保存一个名为animationFrameId的属性来捕获nextFrame函数将在其中运行的动画帧的 ID,另一个名为time的属性将存储游戏初始化的确切时间。这个time属性将被tick()函数用来计算精灵应该移动多少。
  • componentWillUpdate():每次传递新道具(游戏中精灵的位置和大小)时,都会调用此函数。它将检测由于碰撞而必须停止游戏的时间,以便在屏幕上显示游戏。
  • shouldComponentUpdate():如果游戏已经结束,则执行另一项检查以避免重新呈现游戏容器。

其余的函数与 Redux 相关。他们负责通过注入操作和属性将组件连接到存储:

  • mapStateToProps():获取存储中所有精灵的数据,并将其作为道具注入组件。精灵将存储在一个数组中,因此它们将通过索引进行访问。除此之外,还将从状态中检索Score、标记当前游戏是否结束的标志以及标记游戏是否正在进行的标志,并将其注入组件中。
  • mapStateActionsToProps():这将把三个可用的动作(tickbouncestart注入组件,以便组件可以使用它们。

Accessing the sprites data by index is not a recommended practice as indexes can change if the number of sprites grows, but we will use it like this in this app for simplicity reasons.

行动

如前所述,只有三种 Redux 操作可用:

  • tick():计算精灵在屏幕上的下一个位置
  • bounce():让鹦鹉飞起来
  • start():初始化游戏变量

这意味着我们的src/actions/index.js文件应该非常简单:

/*** src/actions/index.js ***/

export function start() {
  return { type: "START" };
}

export function tick(elapsedTime) {
  return { type: "TICK", elapsedTime };
}

export function bounce() {
  return { type: "BOUNCE" };
}

只有tick()操作需要传递有效负载:自上一帧起经过的时间。

减速器

由于我们的操作数量非常有限,我们的 reducer 也将相当简单,并将大部分功能委托给src/sprites.js文件中的 sprites helper 函数:

/*** src/reducers/index.js ***/

import {
  sprites,
  moveSprites,
  checkForCollision,
  getUpdatedScore,
  bounceParrot
} from "../sprites";

const initialState = {
  score: 0,
  gameOver: false,
  isStarted: false,
  sprites
};

export default (state = initialState, action) => {
  switch (action.type) {
    case "TICK":
      return {
        ...state,
        sprites: moveSprites(state.sprites, action.elapsedTime),
        gameOver: checkForCollision(state.sprites[0], 
        state.sprites.slice(1)),
        score: getUpdatedScore(state.sprites, state.score)
      };
    case "BOUNCE":
      return {
        ...state,
        sprites: bounceParrot(state.sprites)
      };
    case "START":
      return {
        ...initialState,
        isStarted: true
      };
    default:
      return state;
  }
};

start()功能只需将isStarted标志设置为true,初始状态默认设置为false。每次游戏结束时,我们都会重复使用这个初始状态。

bounce()将使用精灵模块的bounceParrot()功能为主角设定新的方向

当触发tick()功能时,最重要的变化将发生,因为它需要计算所有移动元素的位置(通过moveSprites()功能),检测鹦鹉是否与任何静态元素发生碰撞(通过checkForCollision()功能),并更新商店中的分数(通过getUpdatedScore()功能)。

正如我们所看到的,游戏的大部分功能都被委派给精灵模块内的助手函数,所以让我们深入研究一下。

精灵模块

精灵模块的结构由精灵阵列和几个导出函数组成:

/*** src/sprites.js ***/

import sound from "react-native-sound";

const coinSound = new sound("coin.wav", sound.MAIN_BUNDLE);
let heightOfRockUp = 25;
let heightOfRockDown = 25;
let heightOfGap = 30;
let heightOfGround = 20;

export const sprites = [
   ...
];

function prepareNewRockSizes() {
  ...
}

function getRockProps(type) {
  ...
}

export function moveSprites(sprites, elapsedTime = 1000 / 60) {
  ...
}

export function bounceParrot(sprites) {
  ...
}

function hasCollided(mainSprite, sprite) {
  ...
}

export function checkForCollision(mainSprite, sprites) {
  ...
}

export function getUpdatedScore(sprites, score) {
  ...
}

本模块首先加载当鹦鹉经过一组岩石时我们将播放的音效,以向用户提供有关其分数增量的反馈。

然后,我们为几个精灵定义一些高度:

  • heightOfRockUp:这是屏幕上部显示的岩石高度。
  • heightOfRockDown:这是屏幕下部显示的岩石高度。
  • heightOfGap:我们将在上下岩石之间创建一个不可见视图,以检测鹦鹉何时通过每套岩石,从而更新分数。这个缺口的高度
  • heightOfGround:这是地面高度的静态值。

此模块中的每个其他项目都在移动或定位屏幕上的精灵时发挥作用。

精灵阵列

这是一个数组,负责存储给定时间内所有精灵的位置和大小。为什么我们使用数组来存储精灵而不是散列映射(对象)?主要用于扩展性;虽然散列映射会使我们的代码更具可读性,但如果我们想添加现有类型的新精灵(就像这个应用中的ground精灵一样),我们需要为每个精灵使用人工键,尽管它们的类型相同。在游戏开发中,使用精灵数组是一种反复出现的模式,它允许将实现从精灵列表中分离出来。

每当我们想要移动精灵时,我们都会更新其在此阵列中的位置:

export const sprites = [
  {
    type: "parrot",
    position: { x: 50, y: 55 },
    velocity: { x: 0, y: 0 },
    size: { width: 10, height: 8 }
  },
  {
    type: "rockUp",
    position: { x: 110, y: 0 },
    velocity: { x: -1, y: 0 },
    size: { width: 15, height: heightOfRockUp }
  },
  {
    type: "rockDown",
    position: { x: 110, y: heightOfRockUp + 30 },
    velocity: { x: -1, y: 0 },
    size: { width: 15, height: heightOfRockDown }
  },
  {
    type: "gap",
    position: { x: 110, y: heightOfRockUp },
    velocity: { x: -1, y: 0 },
    size: { width: 15, height: 30 }
  },
  {
    type: "ground",
    position: { x: 0, y: 80 },
    velocity: { x: -1, y: 0 },
    size: { width: 100, height: heightOfGround }
  },
  {
    type: "ground",
    position: { x: 100, y: 80 },
    velocity: { x: -1, y: 0 },
    size: { width: 100, height: heightOfGround }
  }
];

阵列将存储初始值,用于定位和调整游戏中所有移动精灵的大小。

prepareNewRockSizes()

此函数随机计算下一个上部和下部岩石的尺寸以及它们之间的间隙高度:

function prepareNewRockSizes() {
  heightOfRockUp = 10 + Math.floor(Math.random() * 40);
  heightOfRockDown = 50 - heightOfRockUp;
  heightOfGap = 30;
}

需要注意的是,此函数仅计算新岩石集的高度,而不创建它们。这只是一个准备步骤。

getRockProps()

助手用于格式化岩石的positionsize属性(或gap)

function getRockProps(type) {
  switch (type) {
    case "rockUp":
      return { y: 0, height: heightOfRockUp };
    case "rockDown":
      return { y: heightOfRockUp + heightOfGap, 
               height: heightOfRockDown };
    case "gap":
      return { y: heightOfRockUp, height: heightOfGap };
  }
}

移动精灵()

这是主要功能,因为它计算存储在精灵数组中的每个精灵的新位置。游戏开发依赖于物理学来计算每帧中每个精灵的位置。

例如,如果我们想将一个对象移动到屏幕的右侧,我们需要将其x位置更新若干像素。我们为下一帧添加到对象的x属性的像素越多,它移动的速度就越快(sprite.x = sprite.x + 5;移动的sprite速度是sprite.x = sprite.x + 1;的五倍)

正如我们在下面的示例中所看到的,我们计算每个精灵的新位置的方法基于三个因素:精灵的当前位置、自上一帧(elapsedTime)以来经过的时间以及精灵的重力/速度(i.e. sprite.velocity.y + elapsedTime * gravity

此外,我们将使用辅助函数getRockProps获取岩石的新尺寸和位置。让我们来看看 AUTYT1 函数看起来如何:

export function moveSprites(sprites, elapsedTime = 1000 / 60) {
  const gravity = 0.0001;
  let newSprites = [];

  sprites.forEach(sprite => {
    if (sprite.type === "parrot") {
      var newParrot = {
        ...sprite,
        position: {
          x: sprite.position.x,
          y:
            sprite.position.y +
            sprite.velocity.y * elapsedTime +
            0.5 * gravity * elapsedTime * elapsedTime
        },
        velocity: {
          x: sprite.velocity.x,
          y: sprite.velocity.y + elapsedTime * gravity
        }
      };
      newSprites.push(newParrot);
    } else if (
      sprite.type === "rockUp" ||
      sprite.type === "rockDown" ||
      sprite.type === "gap"
    ) {
      let rockPosition,
        rockSize = sprite.size;
      if (sprite.position.x > 0 - sprite.size.width) {
        rockPosition = {
          x: sprite.position.x + sprite.velocity.x,
          y: sprite.position.y
        };
      } else {
        rockPosition = { x: 100, y: getRockProps(sprite.type).y };
        rockSize = { width: 15, 
                     height: getRockProps(sprite.type).height };
      }
      var newRock = {
        ...sprite,
        position: rockPosition,
        size: rockSize
      };
      newSprites.push(newRock);
    } else if (sprite.type === "ground") {
      let groundPosition;
      if (sprite.position.x > -97) {
        groundPosition = { x: sprite.position.x + sprite.velocity.x,
                           y: 80 };
      } else {
        groundPosition = { x: 100, y: 80 };
      }
      var newGround = { ...sprite, position: groundPosition };
      newSprites.push(newGround);
    }
  });
  return newSprites;
}

大多数情况下,计算精灵的下一个位置是基本的加法(或减法)。让我们举个例子,鹦鹉应该如何移动:

var newParrot = {
        ...sprite,
        position: {
          x: sprite.position.x,
          y:
            sprite.position.y +
            sprite.velocity.y * elapsedTime +
            0.5 * gravity * elapsedTime * elapsedTime
        },
        velocity: {
          x: sprite.velocity.x,
          y: sprite.velocity.y + elapsedTime * gravity
        }
     }

鹦鹉只会垂直移动,其速度取决于重力,因此它的x属性将始终保持不变,而y属性将根据sprite.position.y +``sprite.velocity.y * elapsedTime +``0.5 * gravity * elapsedTime * elapsedTime功能而变化,总之,该功能会在不同的因素中添加经过的时间和重力。

岩石移动方式的计算稍微复杂一些,因为我们需要考虑每次岩石从屏幕上消失(if (sprite.position.x > 0 - sprite.size.width)。由于它们已经被传递,我们需要以不同的高度重新创建它们(rockPosition = { x: 100, y: getRockProps(sprite.type).y }

我们对地面也有同样的行为,一旦它完全放弃屏幕,就必须重新创建地面(if (sprite.position.x > -97)

弹跳箭头()

该函数的唯一任务是改变主要角色的速度,因此它将反转重力的影响向上飞行。在游戏启动时,只要用户点击屏幕,就会调用此函数:

export function bounceParrot(sprites) {
  var newSprites = [];
  var sprite = sprites[0];
  var newParrot = { ...sprite, velocity: { x: sprite.velocity.x,
                    y: -0.05 } };
  newSprites.push(newParrot);
  return newSprites.concat(sprites.slice(1));
}

这是一个简单的操作,我们从sprites数组中获取鹦鹉的精灵数据;我们将其在y轴上的速度更改为负值,以便鹦鹉向上移动。

checkForCollision()

checkForCollision()负责确认是否有任何僵硬的精灵与鹦鹉精灵发生碰撞,以便停止游戏。它将使用hasCollided()作为支持功能,对每个特定精灵执行所需的计算:

function hasCollided(mainSprite, sprite) {
  /*** 
   *** we will check if 'mainSprite' has entered in the
   *** space occupied by 'sprite' by comparing their
   *** position, width and height 
   ***/

  var mainX = mainSprite.position.x;
  var mainY = mainSprite.position.y;
  var mainWidth = mainSprite.size.width;
  var mainHeight = mainSprite.size.height;

  var spriteX = sprite.position.x;
  var spriteY = sprite.position.y;
  var spriteWidth = sprite.size.width;
  var spriteHeight = sprite.size.height;

  /*** 
   *** this if statement checks if any border of mainSprite
   *** sits within the area covered by sprite 
   ***/

  if (
    mainX < spriteX + spriteWidth &&
    mainX + mainWidth > spriteX &&
    mainY < spriteY + spriteHeight &&
    mainHeight + mainY > spriteY
  ) {
    return true;
  }
}

export function checkForCollision(mainSprite, sprites) {
  /*** 
   *** loop through all sprites in the sprites array
   *** checking, for each of them, if there is a
   *** collision with the mainSprite (parrot)
   ***/

  return sprites.filter(sprite => sprite.type !== "gap").find(sprite => {
    return hasCollided(mainSprite, sprite);
  });
}

为了简单起见,我们假设所有精灵都有一个矩形形状(即使岩石在接近尾端时变薄),因为如果我们考虑不同的形状,计算会复杂得多。

总之,checkForCollision()只是在sprites数组中循环寻找任何碰撞的精灵,hasCollided()根据精灵的大小和位置检查碰撞。在一个if语句中,我们比较精灵和鹦鹉精灵的边界,看看这些边界是否占据了屏幕的同一区域。

getUpdatedScore()

sprites 模块中的最后一个功能将检查分数是否需要根据相对于间隙位置的鹦鹉位置进行更新(上部和下部岩石之间的间隙也算作 sprite):

export function getUpdatedScore(sprites, score) {
  var parrot = sprites[0];
  var gap = sprites[3];

  var parrotXPostion = parrot.position.x;
  var gapXPosition = gap.position.x;
  var gapWidth = gap.size.width;

  if (parrotXPostion === gapXPosition + gapWidth) {
    coinSound.play();
    score++;
    prepareNewRockSizes();
  }

  return score;
}

if语句检查鹦鹉在x轴上的位置是否超过了间隙(gapXPosition + gapWidth。当这种情况发生时,我们通过调用其play()方法来播放我们在模块(const coinSound = new sound("coin.wav", sound.MAIN_BUNDLE);的标题中创建的声音。此外,我们将增加score变量,并准备一组新的岩石,以便在当前岩石离开屏幕时进行渲染

常数

我们已经看到了变量WH。如果我们把屏幕分成 100 个部分,它们代表屏幕的一部分。让我们看一下 To.T2A.文件,以更好地理解这一点:

/*** src/constants.js ***/

import { Dimensions } from "react-native";

var { width, height } = Dimensions.get("window");

export const W = width / 100;
export const H = height / 100;

W可以计算为设备屏幕的总宽度除以100单位(在定位精灵时,百分比更容易推理)。H也是如此;总高度除以100即可计算。使用这两个常量,我们可以相对于屏幕大小来定位和调整精灵的大小,因此所有屏幕大小都将显示相同的位置和大小比率

这些常量将用于所有需要响应功能的视觉组件中,因此它们将根据屏幕大小显示和移动不同。这项技术将确保游戏即使在小屏幕上也可以玩,因为精灵将相应地调整大小

现在让我们转到将显示在<GameContainer />中的组件。

鹦鹉

主要角色将由该组件表示,该组件由两个不同的图像组成(同一只鹦鹉的翅膀上下摆动),由<GameContainer />传递的Y位置属性驱动:

/*** src/components/parrot.js ***/

import React from "react";
import { Image } from "react-native";
import { W, H } from "../constants";

export default class Parrot extends React.Component {
  constructor() {
    super();
    this.state = { wings: "down" };
  }

  componentWillUpdate(nextProps, nextState) {
    if (this.props.y < nextProps.y) {
      this.setState({ wings: "up" });
    } else if (this.props.y > nextProps.y) {
      this.setState({ wings: "down" });
    }
  }

  render() {
    let parrotImage;
    if (this.state.wings === "up") {
      parrotImage = require("../img/parrot1.png");
    } else {
      parrotImage = require("../img/parrot2.png");
    }
    return (
      <Image
        source={parrotImage}
        style={{
          position: "absolute",
          resizeMode: "contain",
          left: this.props.x,
          top: this.props.y,
 width: 12 * W,
 height: 12 * W
        }}
      />
    );
  }
}

我们使用一个名为wings的状态变量来选择鹦鹉将出现的图像——当它向上飞行时,将显示翅膀向下的图像,而向下飞行时将显示翅膀向上的图像。计算方法基于鸟在y轴上的位置,该轴作为属性从容器传递:

  • 如果Y位置低于之前的Y位置,则表示鸟正在下降,因此翅膀应该向上
  • 如果Y位置高于之前的Y位置,则表示鸟正在上升,因此翅膀应该下降

对于heightwidth,鹦鹉的大小都固定为12 * W,因为精灵是正方形的,我们希望它的大小与每个屏幕设备的宽度相关。

颠簸

岩石的精灵没有逻辑,基本上是由父组件定位和调整大小的<Image />组件。这是<RockUp />的代码:

/*** src/components/RockUp.js ***/

import React, { Component } from "react";
import { Image } from "react-native";

import { W, H } from "../constants";

export default class RockUp extends Component {
  render() {
    return (
      <Image
        resizeMode="stretch"
        source={require("../img/rock-down.png")}
        style={{
          position: "absolute",
          left: this.props.x,
          top: this.props.y,
          width: this.props.width * W,
          height: this.props.height * H
        }}
      />
    );
  }
}

高度和宽度将通过以下公式计算:this.props.width * Wthis.props.height * H。这将根据设备屏幕和提供的高度和宽度确定岩石的尺寸。

<RockDown />的代码非常相似:

/*** src/components/RockDown.js ***/

import React, { Component } from "react";
import { Image } from "react-native";

import { W, H } from "../constants";

export default class RockDown extends Component {
  render() {
    return (
      <Image
        resizeMode="stretch"
        source={require("../img/rock-up.png")}
        style={{
          position: "absolute",
          left: this.props.x,
          top: this.props.y,
          width: this.props.width * W,
          height: this.props.height * H
        }}
      />
    );
  }
}

构建地面组件类似于岩石精灵。以适当的位置和大小渲染的图像将足以用于此组件:

/*** src/components/Ground.js ***/

import React, { Component } from "react";
import { Image } from "react-native";

import { W, H } from "../constants";

export default class Ground extends Component {
  render() {
    return (
      <Image
        resizeMode="stretch"
        source={require("../img/ground.png")}
        style={{
          position: "absolute",
          left: this.props.x,
          top: this.props.y * H,
          width: this.props.width * W,
          height: this.props.height * H
        }}
      />
    );
  }
}

在这种情况下,我们将使用H对地面图像进行相对定位。

分数

我们决定使用数字图像来渲染分数,因此我们需要加载它们并根据用户的分数选择适当的数字:

/*** src/components/Score.js ***/

import React, { Component } from "react";
import { View, Image } from "react-native";

import { W, H } from "../constants";

export default class Score extends Component {
  getSource(num) {
    switch (num) {
      case "0":
        return require("../img/0.png");
      case "1":
        return require("../img/1.png");
      case "2":
        return require("../img/2.png");
      case "3":
        return require("../img/3.png");
      case "4":
        return require("../img/4.png");
      case "5":
        return require("../img/5.png");
      case "6":
        return require("../img/6.png");
      case "7":
        return require("../img/7.png");
      case "8":
        return require("../img/8.png");
      case "9":
        return require("../img/9.png");
      default:
        return require("../img/0.png");
    }
  }

  render() {
    var scoreString = this.props.score.toString();
    var scoreArray = [];
    for (var index = 0; index < scoreString.length; index++) {
      scoreArray.push(scoreString[index]);
    }

    return (
      <View
        style={{
          position: "absolute",
          left: 47 * W,
          top: 10 * H,
          flexDirection: "row"
        }}
      >
        {scoreArray.map(
          function(item, i) {
            return (
              <Image
                style={{ width: 10 * W }}
                key={i}
                resizeMode="contain"
                source={this.getSource(item)}
              />
            );
          }.bind(this)
        )}
      </View>
    );
  }
}

我们正在render方法中进行以下操作:

  • 将分数转换为字符串
  • 将字符串转换为数字列表
  • 使用支持的getSource()功能将此数字列表转换为图像列表

React Native<Image />中的一个限制是它的源不能作为变量。因此,我们使用这个小技巧从我们的getSource()方法中检索源,该方法实际获取所有可能的图像,并通过switch/case子句返回正确的图像。

开始

开始屏幕包括两个图像:

  • 标志
  • 说明如何启动游戏的开始按钮(点击屏幕上的任意位置)
/*** src/components/Start.js ***/

import React, { Component } from "react";
import { Text, View, StyleSheet, Image } from "react-native";

import { W, H } from "../constants";

export default class Start extends Component {
  render() {
    return (
      <View style={{ position: "absolute", left: 20 * W, top: 3 * H }}>
        <Image
          resizeMode="contain"
          source={require("../img/logo.png")}
          style={{ width: 60 * W }}
        />
        <Image
          resizeMode="contain"
          style={{ marginTop: 15, width: 60 * W }}
          source={require("../img/tap.png")}
        />
      </View>
    );
  }
}

我们再次使用HW常量,以确保元素在每个设备屏幕上的正确位置。

游戏结束

当鹦鹉与岩石或地面相撞时,我们应该在屏幕上显示游戏。此屏幕仅包含两个图像:

  • 游戏结束标志
  • 重新启动游戏的按钮

我们先来看看游戏结束标志:

/*** src/components/GameOver.js ***/

import React, { Component } from "react";
import { Image } from "react-native";

import { W, H } from "../constants";

export default class GameOver extends Component {
  render() {
    return (
      <Image
        style={{
          position: "absolute",
          left: 15 * W,
          top: 30 * H
        }}
        resizeMode="stretch"
        source={require("../img/game-over.png")}
      />
    );
  }
}

现在,让我们转到重置游戏按钮。

斯塔加因

实际上,重置按钮只是一个标志,因为用户不仅可以点击按钮,还可以点击屏幕上的任何地方来启动游戏。在任何情况下,我们将使用HW常量在每个屏幕上正确定位此按钮:

/*** src/components/StartAgain.js ***/

import React, { Component } from "react";
import { Text, View, StyleSheet, TouchableOpacity, Image } 
from "react-native";

import { W, H } from "../constants";

export default class StartAgain extends Component {
  render() {
    return (
      <Image
        style={{ position: "absolute", left: 35 * W, top: 40 * H }}
        resizeMode="contain"
        source={require("../img/reset.png")}
      />
    );
  }
}

总结

游戏是一种非常特殊的应用。它们基于在屏幕上显示和移动精灵,具体取决于时间和用户交互。这就是为什么我们花了本章大部分时间来解释如何以最高效的方式轻松显示所有图像,以及如何定位和调整它们的大小。

我们还回顾了一个常见的技巧,即根据设备屏幕的高度和宽度来定位和调整精灵的大小。

尽管 Redux 不是专门为游戏设计的,但它被用来存储和分发精灵在应用组件中的数据。

一般来说,我们证明 React Native 可以用于构建高性能的游戏,尽管它缺乏特定于游戏的工具,但我们可以生成非常可读的代码,这意味着它应该易于扩展和维护。事实上,在此阶段可以创建一些非常简单的扩展,使游戏更加有趣和可玩:通过特定数量的障碍物后提高速度,减小或增大间隙大小,同时在屏幕上显示多组岩石,等等。

下一章将回顾一种更为传统的应用的蓝图:电子商务应用。