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
コーディング
まず最初に、共通設定ファイルを作成し、編集します。
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
}
次に天井/床/障害物となる共通のオブジェクトクラス作成します
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
}}
/>
);
}
}
次は、物理演算クラスを作成します
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を以下の様に修正します。
//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
}
})
以上をビルドした結果が以下になります。
FlappyBirdっぽくなってますね。
ただ筆者の場合、Physics.jsのタッチ処理あたりがあやしく、いきなりバードが急上昇したりする事象を確認済みです。これは、次回以降のFlappyBird本番にて改善が確認出来次第、こちらの記事も修正して参ります。
最後に
今回は、FlappyBirdの物理エンジン部分の実装だけをしました。
次回は、いよいよFlappyBirdに実装に入っていきます。