はじめに
フロントエンジニアの、YAMATAKUです。
前回までは、ReactNativeで「もぐら叩き」の英語サンプルを使いながら、ReactNativeについて学びました。
引き続き、今回は、ReactNativeで「FlappyBird(フラッピーバード)」を作成していきたいと思います。
環境構築については、以下の記事をご参考ください。
・React Nativeでゲームを作る(ための、はじめの一歩):
https://qiita.com/team-lot/items/3fe8d3f77535a7cae8b6
尚、物理演算の部分のみ抜き出したサンプルもありますので、興味があれば参考にしてみてください
React Native ゲーム開発 FlappyBird 番外編
https://qiita.com/team-lot/items/a3ae47ffd63722e896eb
出典
今回のソースコードは以下からの引用になります
https://github.com/lepunk/react-native-videos/tree/master/FlappyBird
プロジェクト作成/ビルド
適当なディレクトリに移動して、プロジェクトを作成していきます。
react-native-game-engine、matter-jsもインストールしておきます。
$ react-native init FlappyBird
$ cd FlappyBird
$ npm install react-native-game-engine matter-js --save
環境構築ができていた場合、Androidエミュレータが起動し、「Welcome to React」というタイトルページが起動したと思います。
画像/フォント コピー
前述の出典サンプルより、画像/フォントデータを、作成したプロジェクトへ以下のように配置しましょう。
FlappyBird/assets/img
FlappyBird/assets/fonts
コーディング
まずは、画像ファイルパス管理ファイルを作成します。
export default (Images = {
background: require('./img/background.png'),
floor: require('./img/floor.png'),
pipeCore: require('./img/pipe_core.png'),
pipeTop: require('./img/pipe_top.png'),
bird1: require('./img/bird1.png'),
bird2: require('./img/bird2.png'),
bird3: require('./img/bird3.png'),
});
次にグローバル定数を管理するConstants.jsを作成します。
import { Dimensions } from 'react-native';
export default (Constants = {
MAX_WIDTH: Dimensions.get('screen').width,
MAX_HEIGHT: Dimensions.get('screen').height,
GAP_SIZE: 220,
PIPE_WIDTH: 100,
BIRD_WIDTH: 50,
BIRD_HEIGHT: 41,
});
そしてここからは、描画関連のクラスを作成していきます。
まずは「地面」の画像を管理するFloorクラスから作成します。
プロジェクトのルートに、Floor.jsを作成して、以下の様に記述します。
もし、VSCodeエディタ+React Native Snippetプラグインがインストールされていれば、
エディタにカーソルを合わせた状態で【rnc】と打ち込んでみてください。ReactNativeのクラスコンポーネントのテンプレートが候補として出てきます。
Floor.jsを以下の様に記述します。
import React, { Component } from 'react';
import { View, Image } from 'react-native';
import Images from './assets/Images';
export default class Floor extends Component {
render() {
const width = this.props.body.bounds.max.x - this.props.body.bounds.min.x;
const height = this.props.body.bounds.max.y - this.props.body.bounds.min.y;
const x = this.props.body.position.x - width / 2;
const y = this.props.body.position.y - height / 2;
const imageIterations = Math.ceil(width / height);
return (
<View
style={{
position: 'absolute',
left: x,
top: y,
width: width,
height: height,
overflow: 'hidden',
flexDirection: 'row',
}}
>
{Array.apply(null, Array(imageIterations)).map((el, idx) => {
return (
<Image
style={{
width: height,
height: height,
}}
key={idx}
source={Images.floor}
resizeMode="stretch"
/>
);
})}
</View>
);
}
}
次に、主人公である鳥クラスBird.jsを作成します。
import React, { Component } from 'react';
import { View, Image } from 'react-native';
import Images from './assets/Images';
export default class Bird extends Component {
render() {
const width = this.props.body.bounds.max.x - this.props.body.bounds.min.x;
const height = this.props.body.bounds.max.y - this.props.body.bounds.min.y;
const x = this.props.body.position.x - width / 2;
const y = this.props.body.position.y - height / 2;
let image = Images['bird' + this.props.pose];
return (
<Image
style={{
position: 'absolute',
left: x,
top: y,
width: width,
height: height,
}}
resizeMode="stretch"
source={image}
/>
);
}
}
続いて、障害物となるPipe.jsを作成します。
import React, { Component } from 'react';
import { View, Image } from 'react-native';
import Images from './assets/Images';
export default class Pipe extends Component {
render() {
const width = this.props.body.bounds.max.x - this.props.body.bounds.min.x;
const height = this.props.body.bounds.max.y - this.props.body.bounds.min.y;
const x = this.props.body.position.x - width / 2;
const y = this.props.body.position.y - height / 2;
const pipeRatio = 160 / width; //画像オリジナルサイズが160
const pipeHeight = 33 * pipeRatio;
const pipeIterations = Math.ceil(height / pipeHeight);
return (
<View
style={{
position: 'absolute',
left: x,
top: y,
width: width,
height: height,
overflow: 'hidden',
flexDirection: 'column',
}}
>
{Array.apply(null, Array(pipeIterations)).map((el, idx) => {
return (
<Image
style={{ width: width, height: pipeHeight }}
key={idx}
resizeMode="stretch"
source={Images.pipeCore}
/>
);
})}
</View>
);
}
}
世界一有名なゲームの土管的な感じを表現するための頭のでっぱり部分を描画するクラスも別ファイルで定義します
import React, { Component } from 'react';
import { View, Image } from 'react-native';
import Images from './assets/Images';
export default class PipeTop extends Component {
render() {
const width = this.props.body.bounds.max.x - this.props.body.bounds.min.x;
const height = this.props.body.bounds.max.y - this.props.body.bounds.min.y;
const x = this.props.body.position.x - width / 2;
const y = this.props.body.position.y - height / 2;
return (
<Image
style={{
position: 'absolute',
left: x,
top: y,
width: width,
height: height,
}}
resizeMode="stretch"
source={Images.pipeTop}
/>
);
}
}
ゲームの核となる、重力&アップデート処理を行うPhysics.jsを作成します
import Matter from 'matter-js';
import Pipe from './Pipe';
import PipeTop from './PipeTop';
let tick = 0;
let pose = 1;
let pipes = 0;
export const randomBetween = (min, max) => {
return Math.floor(Math.random() * (max - min + 1) + min);
};
export const resetPipes = () => {
pipes = 0;
};
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 const addPipesAtLocation = (x, world, entities) => {
let [pipe1Height, pipe2Height] = generatePipes();
let pipeTopWidth = Constants.PIPE_WIDTH + 20;
let pipeTopHeight = (pipeTopWidth / 205) * 95;
pipe1Height = pipe1Height - pipeTopHeight;
let pipe1Top = Matter.Bodies.rectangle(
x,
pipe1Height + pipeTopHeight / 2,
pipeTopWidth,
pipeTopHeight,
{ isStatic: true }
);
let pipe1 = Matter.Bodies.rectangle(x, pipe1Height / 2, Constants.PIPE_WIDTH, pipe1Height, {
isStatic: true,
});
pipe2Height = pipe2Height - pipeTopHeight;
let pipe2Top = Matter.Bodies.rectangle(
x,
Constants.MAX_HEIGHT - 50 - pipe2Height - pipeTopHeight / 2,
pipeTopWidth,
pipeTopHeight,
{ isStatic: true }
);
let pipe2 = Matter.Bodies.rectangle(
x,
Constants.MAX_HEIGHT - 50 - pipe2Height / 2,
Constants.PIPE_WIDTH,
pipe2Height,
{ isStatic: true }
);
Matter.World.add(world, [pipe1, pipe1Top, pipe2, pipe2Top]);
entities['pipe' + (pipes + 1)] = {
body: pipe1,
renderer: Pipe,
scored: false,
};
entities['pipe' + (pipes + 2)] = {
body: pipe2,
renderer: Pipe,
scored: false,
};
entities['pipe' + (pipes + 1) + 'Top'] = {
body: pipe1Top,
renderer: PipeTop,
scored: false,
};
entities['pipe' + (pipes + 2) + 'Top'] = {
body: pipe2Top,
renderer: PipeTop,
scored: false,
};
pipes += 2;
};
const Physics = (entities, { touches, time, dispatch }) => {
let engine = entities.physics.engine;
let world = entities.physics.world;
let bird = entities.bird.body;
let hadTouches = false;
touches
.filter(t => t.type === 'press')
.forEach(t => {
if (!hadTouches) {
if (world.gravity.y === 0.0) {
world.gravity.y = 1.2;
addPipesAtLocation(Constants.MAX_WIDTH * 2 - Constants.PIPE_WIDTH / 2, world, entities);
addPipesAtLocation(Constants.MAX_WIDTH * 3 - Constants.PIPE_WIDTH / 2, world, entities);
}
hadTouches = true;
Matter.Body.setVelocity(bird, {
x: bird.velocity.x,
y: -10,
});
}
});
Matter.Engine.update(engine, time.delta);
Object.keys(entities).forEach(key => {
if (key.indexOf('pipe') === 0 && entities.hasOwnProperty(key)) {
Matter.Body.translate(entities[key].body, { x: -2, y: 0 });
if (key.indexOf('Top') !== -1 && parseInt(key.replace('pipe', '')) % 2 === 0) {
if (entities[key].body.position.x <= bird.position.x && !entities[key].scored) {
entities[key].scored = true;
dispatch({ type: 'score' });
}
if (entities[key].body.position.x <= -1 * (Constants.PIPE_WIDTH / 2)) {
let pipeIndex = parseInt(key.replace('pipe', ''));
delete entities['pipe' + (pipeIndex - 1) + 'Top'];
delete entities['pipe' + (pipeIndex - 1)];
delete entities['pipe' + pipeIndex + 'Top'];
delete entities['pipe' + pipeIndex];
addPipesAtLocation(Constants.MAX_WIDTH * 2 - Constants.PIPE_WIDTH / 2, world, entities);
}
}
} else if (key.indexOf('floor') === 0) {
if (entities[key].body.position.x <= (-1 * Constants.MAX_WIDTH) / 2) {
Matter.Body.setPosition(entities[key].body, {
x: Constants.MAX_WIDTH + Constants.MAX_WIDTH / 2,
y: entities[key].body.position.y,
});
} else {
Matter.Body.translate(entities[key].body, { x: -2, y: 0 });
}
}
});
tick += 1;
if (tick % 5 === 0) {
pose = pose + 1;
if (pose > 3) {
pose = 1;
}
entities.bird.pose = pose;
}
return entities;
};
export default Physics;
最後に、App.jsを以下に修正して完成です。
import React, { Component } from 'react';
import {
Dimensions,
StyleSheet,
Text,
View,
StatusBar,
Alert,
TouchableOpacity,
Image,
} from 'react-native';
import Matter from 'matter-js';
import { GameEngine } from 'react-native-game-engine';
import Bird from './Bird';
import Floor from './Floor';
import Physics, { resetPipes } from './Physics';
import Constants from './Constants';
import Images from './assets/Images';
export default class App extends Component {
constructor(props) {
super(props);
this.state = {
running: true,
score: 0,
};
this.gameEngine = null;
this.entities = this.setupWorld();
}
setupWorld = () => {
let engine = Matter.Engine.create({ enableSleeping: false });
let world = engine.world;
world.gravity.y = 0.0;
let bird = Matter.Bodies.rectangle(
Constants.MAX_WIDTH / 2,
Constants.MAX_HEIGHT / 2,
Constants.BIRD_WIDTH,
Constants.BIRD_HEIGHT
);
let floor1 = Matter.Bodies.rectangle(
Constants.MAX_WIDTH / 2,
Constants.MAX_HEIGHT - 25,
Constants.MAX_WIDTH + 4,
50,
{ isStatic: true }
);
let floor2 = Matter.Bodies.rectangle(
Constants.MAX_WIDTH + Constants.MAX_WIDTH / 2,
Constants.MAX_HEIGHT - 25,
Constants.MAX_WIDTH + 4,
50,
{ isStatic: true }
);
Matter.World.add(world, [bird, floor1, floor2]);
Matter.Events.on(engine, 'collisionStart', event => {
var pairs = event.pairs;
this.gameEngine.dispatch({ type: 'game-over' });
});
return {
physics: { engine: engine, world: world },
floor1: { body: floor1, renderer: Floor },
floor2: { body: floor2, renderer: Floor },
bird: { body: bird, pose: 1, renderer: Bird },
};
};
onEvent = e => {
if (e.type === 'game-over') {
//Alert.alert("Game Over");
this.setState({
running: false,
});
} else if (e.type === 'score') {
this.setState({
score: this.state.score + 1,
});
}
};
reset = () => {
resetPipes();
this.gameEngine.swap(this.setupWorld());
this.setState({
running: true,
score: 0,
});
};
render() {
return (
<View style={styles.container}>
<Image source={Images.background} style={styles.backgroundImage} resizeMode="stretch" />
<GameEngine
ref={ref => {
this.gameEngine = ref;
}}
style={styles.gameContainer}
systems={[Physics]}
running={this.state.running}
onEvent={this.onEvent}
entities={this.entities}
>
<StatusBar hidden={true} />
</GameEngine>
<Text style={styles.score}>{this.state.score}</Text>
{!this.state.running && (
<TouchableOpacity style={styles.fullScreenButton} onPress={this.reset}>
<View style={styles.fullScreen}>
<Text style={styles.gameOverText}>Game Over</Text>
<Text style={styles.gameOverSubText}>Try Again</Text>
</View>
</TouchableOpacity>
)}
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
backgroundImage: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
width: Constants.MAX_WIDTH,
height: Constants.MAX_HEIGHT,
},
gameContainer: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
},
gameOverText: {
color: 'white',
fontSize: 48,
fontFamily: '04b_19',
},
gameOverSubText: {
color: 'white',
fontSize: 24,
fontFamily: '04b_19',
},
fullScreen: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'black',
opacity: 0.8,
justifyContent: 'center',
alignItems: 'center',
},
score: {
position: 'absolute',
color: 'white',
fontSize: 72,
top: 50,
left: Constants.MAX_WIDTH / 2 - 20,
textShadowColor: '#444444',
textShadowOffset: { width: 2, height: 2 },
textShadowRadius: 2,
fontFamily: '04b_19',
},
fullScreenButton: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
flex: 1,
},
});
カスタムフォントについて (今回:FB.ttfの場合)
iOS
- XcodeでResorcesグループ作成 > カスタムフォント(.ttf、.otfなど)をインポートしておく
- plistに以下を追加する
UIAppFonts
FB.ttf //フォント名を任意で書き換える
- Xcode > TARGET > FlappyBird > Build Phases > Copy Bundle Resource にカスタムフォントが登録されているか確認
Android
- android/app/src/main/assets/fonts/FB.ttf にカスタムフォントデータをコピーする
動作確認
難易度は高いです。。。
Constants.jsのGAP_SIZEで調整可能です。
Android動作不具合について
iOSでは上記実装で正常に動作しましたが、Androidでは、どこかに不具合があるようで、完全に処理落ち状態でプレイするレベルになりませんでした(どなたか原因を教えてくださいますと幸いです)
最後に
Androidがまったくゲームにならないという現象に見舞われました。react-native-game-engineのせいなのか、Matter.jsのせいなのか。。。これを検証するレベルまで到底達してないため、今回はいさぎよく諦めることとします(どなたか助けてください)。
それでも、以下にコードを上げておきます