React Native ゲーム開発 FlappyBird 番外編

# React Native ゲーム開発 FlappyBird 番外編

フロントエンジニアの、YAMATAKUです。
今回は、FlappyBirdの開発にあたり、

- react-native-game-engine
- Matter.js

上記2プラグインを使用して、物理演算の実装だけに注目した実装をしていきます。
環境構築については、以下の記事をご参考ください。

・React Nativeでゲームを作る(ための、はじめの一歩):
https://qiita.com/team-lot/items/3fe8d3f77535a7cae8b6

## 出典

Making a Production-Ready Flappy Bird in React Native
https://medium.com/better-programming/flappy-bird-with-react-native-game-engine-and-matter-js-d5673f50eb9

## プロジェクト作成/ビルド
例によって、ターミナル起動し、適当なディレクトリに移動して、プロジェクトを作成していきます。

```
$ react-native init FlappyBirdPhysicsEngine
$ cd FlappyBirdPhysicsEngine
$ react-native run-android 
```
環境構築ができていた場合、Androidエミュレータが起動し、「Welcome to React」というタイトルページが起動したと思います。


## プラグイン

今回の主となるプラグイン(react-native-game-engine、Matter.js)をインストールします。

```
$ npm install react-native-game-engine matter-js --save
```

## コーディング

まず最初に、共通設定ファイルを作成し、編集します。

```jsx:./Const.js

import { Dimensions } from 'react-native';

export default Constants = {
    MAX_WIDTH: Dimensions.get("screen").width,
    MAX_HEIGHT: Dimensions.get("screen").height,
    GAP_SIZE: 200, // gap between the two parts of the pipe
    PIPE_WIDTH: 100 // width of the pipe
}
```

次に天井/床/障害物となる共通のオブジェクトクラス作成します

```jsx:Wall.js
import React, { Component } from 'react';
import { View } from 'react-native';

export default class Wall extends Component {
  render() {
    const width = this.props.size[0];
    const height = this.props.size[1];
    const x = this.props.body.position.x - width / 2;
    const y = this.props.body.position.y - height / 2;
    return (
      <View 
        style={{
          position: "absolute",
          left: x,
          top: y,
          width: width,
          height: height,
          backgroundColor: this.props.color
        }}
      />
    );
  }
}
```
次は、物理演算クラスを作成します

```jsx:./Physics.js
import Matter from 'matter-js';

const Physics = (entities, { touches, time }) => {
  let engine = entities.physics.engine;
  let bird = entities.bird.body;

  //タッチ処理
  touches.filter(t => t.type === "press").forEach(t => {
    Matter.Body.applyForce(
      bird,
      bird.position,
      {
        x: 0.00,
        y: -0.05
      }
    );
  });

  for (let i = 1; i <= 4; i++) {
    if (entities["pipe" + i].body.position.x <= -1 * (Constants.PIPE_WIDTH / 2)) {
      Matter.Body.setPosition(
        entities["pipe" + i].body,
        {
          x: Constants.MAX_WIDTH * 2 - (Constants.PIPE_WIDTH / 2),
          y: entities["pipe" + i].body.position.y
        }
      );
    }
    else {
      Matter.Body.translate(
        entities["pipe" + i].body,
        {x: -1, y: 0}
      );
    }
  }
  
  Matter.Engine.update(engine, time.delta);

  return entities;
}

export default Physics;
```
最後に　メインビューとなるApp.jsを以下の様に修正します。

```jsx:./App.js
//App.jsの初期コードを全消しして、rncでスペニットからコードを生成する

import React, { Component } from 'react';
import { View, StyleSheet, StatusBar, TouchableOpacity, Alert, Text} from 'react-native';
import Matter from 'matter-js';
import { GameEngine } from 'react-native-game-engine';
import Bird from './Bird';
import Constants from './Constants';
import Physics from './Physics';
import Wall from './Wall';

export const randomBetween = (min, max) => {
  return Math.floor(Math.random() * (max - min + 1) + min);
}

export const generatePipes = () => {
  let topPipeHeight = randomBetween(100, (Constants.MAX_HEIGHT / 2) - 100);
  let bottomPipeHeight = Constants.MAX_HEIGHT - topPipeHeight - Constants.GAP_SIZE;
  let sizes = [topPipeHeight, bottomPipeHeight];

  if (Math.random() < 0.5) {
    sizes = sizes.reverse();
  }

  return sizes;
}

export default class App extends Component {
  constructor(props) {
    super(props);

    this.state = {
      running: true
    };

    this.gameEngine = null;
    this.entities = this.setupWorld();
  }

  //毎tick描画
  setupWorld = () => {
    let engine = Matter.Engine.create({
      enableSleeping: false
    });
    let world = engine.world;

    //50x50の鳥の体を作成
    let bird = Matter.Bodies.rectangle(
      Constants.MAX_WIDTH / 4,   //画面横位置から
      Constants.MAX_HEIGHT / 2,  //画面縦位置から
      50,                        //横サイズ
      50                         //縦サイズ
    );

    //床
    let floor = Matter.Bodies.rectangle(
      Constants.MAX_WIDTH / 2,
      Constants.MAX_HEIGHT - 25,
      Constants.MAX_WIDTH,
      50,
      { isStatic: true }
    );

    //天井
    let ceiling = Matter.Bodies.rectangle(
      Constants.MAX_WIDTH / 2,
      25,
      Constants.MAX_WIDTH,
      50,
      { isStatic: true }
    );

    //障害物1&2
    let [pipe1Height, pipe2Height] = generatePipes();
    let pipe1 = Matter.Bodies.rectangle(
      Constants.MAX_WIDTH - (Constants.PIPE_WIDTH / 2),
      pipe1Height / 2,
      Constants.PIPE_WIDTH,
      pipe1Height,
      { isStatic: true }
    )
    let pipe2 = Matter.Bodies.rectangle(
      Constants.MAX_WIDTH - (Constants.PIPE_WIDTH / 2),
      Constants.MAX_HEIGHT - (pipe2Height / 2),
      Constants.PIPE_WIDTH,
      pipe2Height,
      { isStatic: true }
    )

    //障害物3&4
    let [pipe3Height, pipe4Height] = generatePipes();
    let pipe3 = Matter.Bodies.rectangle(
      Constants.MAX_WIDTH * 2 - (Constants.PIPE_WIDTH / 2),
      pipe3Height / 2,
      Constants.PIPE_WIDTH,
      pipe3Height,
      { isStatic: true }
    )
    let pipe4 = Matter.Bodies.rectangle(
      Constants.MAX_WIDTH * 2 - (Constants.PIPE_WIDTH / 2),
      Constants.MAX_HEIGHT - (pipe4Height / 2),
      Constants.PIPE_WIDTH,
      pipe4Height,
      { isStatic: true }
    )

    //Matter.World.add(ゲームシーン, [ゲーム内オブジェクト配列])
    Matter.World.add(world, [bird, floor, ceiling, pipe1, pipe2, pipe3, pipe4]);      

    //衝突判定
    Matter.Events.on(engine, 'collisionStart', (event) => {
      var pairs = event.pairs;
      console.log("Pair no visible: ", pairs)
      console.log("Pair visible: ", pairs[0]);
      console.log("colision between " + pairs[0].bodyA.label + " - " + pairs[0].bodyB.label);


      this.gameEngine.dispatch({ type: "game-over"}); //onEventにgame-overを発疹
    })

    //GameEngineに返す描画プロパティ配列?
    return {
      physics: { engine: engine, world: world },
      bird: { body: bird, size: [50, 50], color: 'red', renderer: Bird },
      floor: { body: floor, size: [Constants.MAX_WIDTH, 50], color: "green", renderer: Wall},
      ceiling: { body: ceiling, size: [Constants.MAX_WIDTH, 50], color: "green", renderer: Wall },
      pipe1: { body: pipe1, size: [Constants.PIPE_WIDTH, pipe1Height], color: "green", renderer: Wall },
      pipe2: { body: pipe2, size: [Constants.PIPE_WIDTH, pipe2Height], color: "green", renderer: Wall },
      pipe3: { body: pipe3, size: [Constants.PIPE_WIDTH, pipe3Height], color: "green", renderer: Wall },
      pipe4: { body: pipe4, size: [Constants.PIPE_WIDTH, pipe4Height], color: "green", renderer: Wall },
    }
  }

  onEvent = (e) => {
    if (e.type === "game-over") {
      this.setState({
        running: false
      });
    }
  }
  
  reset = () => {
    this.gameEngine.swap(this.setupWorld());
    this.setState({
      running: true
    });
  }

  render() {
    return (
      <View style={styles.container}>
        <GameEngine
          ref={(ref) => { this.gameEngine = ref; }}
          style={styles.gameContainer}
          running={this.state.running}
          systems={[Physics]}
          onEvent={this.onEvent}
          entities={this.entities}>
          <StatusBar hidden={true} />
        </GameEngine>
        {!this.state.running &&
          <TouchableOpacity
            style={styles.fullScreenButton}
            onPress={this.reset}>
            <View style={styles.fullScreen}>
              <Text style={styles.gameOverText}>GameOver</Text>
            </View>
          </TouchableOpacity>
        }
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
  },
  gameContainer: {
    position: 'absolute',
    top: 0,
    bottom: 0,
    left: 0,
    right: 0,
  },
  gameOverText: {
    color: 'white',
    fontSize: 48
  },
  fullScreen: {
    position: 'absolute',
    top: 0,
    bottom: 0,
    left: 0,
    right: 0,
    backgroundColor: 'black',
    opacity: 0.8,
    justifyContent: 'center',
    alignItems: 'center'
  },
  fullScreenButton: {
    position: 'absolute',
    top: 0,
    bottom: 0,
    left: 0,
    right: 0,
    flex: 1
  }
})
```
以上をビルドした結果が以下になります。

![タイトルなし.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/616010/54d367af-0256-f52c-5b08-f101b5a27eaf.gif)


FlappyBirdっぽくなってますね。
ただ筆者の場合、Physics.jsのタッチ処理あたりがあやしく、いきなりバードが急上昇したりする事象を確認済みです。これは、次回以降のFlappyBird本番にて改善が確認出来次第、こちらの記事も修正して参ります。

# 最後に
今回は、FlappyBirdの物理エンジン部分の実装だけをしました。
次回は、いよいよFlappyBirdに実装に入っていきます。