Воссоздание надоедливой вирусной хитовой игры
Flappy Bird - одна из тех игр, о которых знает большинство людей, даже если они никогда в нее не играли. Первоначально он был выпущен в мае 2013 года вьетнамским разработчиком Донг Нгуеном, но не стал популярным до начала 2014 года, когда он резко поднялся на позицию номер один в iOS App Store.
Игра, несомненно, вызывает привыкание и в то же время раздражает, но главный вопрос заключается в следующем: можно ли ее сделать в React Native? Ответ очевиден: да, иначе этого поста бы не существовало!
TL; DR # 1: Предпочитаете смотреть это в видеоформате?
TL; DR # 2: Просто хотите код? Вот: https://github.com/lepunk/react-native-videos/tree/master/FlappyBird
Чтобы воссоздать эту игру, я решил снова использовать react-native-game-engine (RNGE). Если вы еще этого не сделали, прочитайте мой предыдущий пост как введение в движок. Однако, в отличие от Snake, Flappy Bird обладает некоторыми базовыми физическими особенностями - один только движок, работающий в игре, не поможет.
Введите Matter.js, хорошо зарекомендовавший себя 2D физический движок для Javascript:
npm install react-native-game-engine matter-js --save
Таким образом, у нас есть все необходимое для создания базовой версии Flappy Bird.
Как обычно, давайте начнем с настройки некоторых констант:
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
}
Затем нам нужно настроить наши «сущности» и «системы» для RNGE. Сначала давайте просто создадим наш мир и нарисуем «Птицу», которая будет представлена ярко-красным квадратом.
Наш App.js будет выглядеть примерно так:
import React, { Component } from 'react';
import { StyleSheet, View, } from 'react-native';
import Matter from "matter-js";
import { GameEngine } from "react-native-game-engine";
import Bird from './Bird';
import Constants from './Constants';
export default class App extends Component {
constructor(props){
super(props);
this.state = {
running: true
};
this.gameEngine = null;
this.entities = this.setupWorld();
}
setupWorld = () => {
let engine = Matter.Engine.create({ enableSleeping: false });
let world = engine.world;
let bird = Matter.Bodies.rectangle( Constants.MAX_WIDTH / 4, Constants.MAX_HEIGHT / 2, 50, 50);
Matter.World.add(world, [bird]);
return {
physics: { engine: engine, world: world },
bird: { body: bird, size: [50, 50], color: 'red', renderer: Bird},
}
}
render() {
return (
<View style={styles.container}>
<GameEngine
ref={(ref) => { this.gameEngine = ref; }}
style={styles.gameContainer}
running={this.state.running}
entities={this.entities}>
<StatusBar hidden={true} />
</GameEngine>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
gameContainer: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
},
});
Важные части здесь находятся в методе setupWorld.
let engine = Matter.Engine.create({ enableSleeping: false });
let world = engine.world;
let bird = Matter.Bodies.rectangle( Constants.MAX_WIDTH / 4, Constants.MAX_HEIGHT / 2, 50, 50);
Matter.World.add(world, [bird]);
Мы создаем новый «двигатель» Материи. Затем мы создаем «тело» птицы виртуального размера 50x50, центрируемое по вертикали и в первых 25% экрана по горизонтали. Наконец, мы добавляем тело птицы в созданный нами мир.
Важно отметить, что Matter не обрабатывает рендеринг нашей птицы, а только рассчитывает ее положение на экране. Рендеринг будет происходить на каждом тике с помощью React Native и RNGE, определенных в операторе return setupWorld:
return {
physics: { engine: engine, world: world },
bird: { body: bird, size: [50, 50], color:'red',renderer: Bird},
}
Чтобы что-то увидеть, нам нужно создать компонент Bird.js, который будет выглядеть примерно так:
Здесь нет ничего особенного. Единственное, что я хочу отметить, - это значения x и y. Мы извлекаем это из опоры тела птицы, которой управляет Matter.js.

Большой. После более чем 100 строк кода на белом экране появляется красный квадрат. Что теперь?
Мир, который мы создали в Matter, по умолчанию имеет гравитацию, равную 1.0. Чтобы заставить его работать, нам нужно периодически вызывать метод обновления в движке, чтобы Matter мог пересчитать положение каждого тела. Как обсуждалось в моем предыдущем посте, RNGE предоставляет удобный способ периодически вызывать набор функций, называемых «системами».
Давайте изменим наш App.js, добавив include:
import Physics from './Physics';
и добавляем систему Physics в наш GameEngine
<GameEngine
ref={(ref) => { this.gameEngine = ref; }}
style={styles.gameContainer}
running={this.state.running}
systems={[Physics]}
entities={this.entities}>
</GameEngine>
Все, что нам сейчас нужно, это файл Physics.js, который будет вызываться при каждом тике.

Теперь мы куда-то идем!
Хорошо, давайте добавим потолок и пол. Измените наш метод setupWorld () в App.js на это:
setupWorld = () => {
let engine = Matter.Engine.create({ enableSleeping: false });
let world = engine.world;
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 });
Matter.World.add(world, [bird, floor, ceiling]);
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 },
}
}
Все, что мы делаем, это добавляем два новых тела в мир Материи: пол и потолок. Они похожи на bird, за исключением того, что их атрибут isStatic имеет значение true, что говорит Материи, что физика на них не влияет.
Их средство визуализации называется Wall - давайте реализуем его:
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
}} />
);
}
}
Код для Wall на самом деле такой же, как и для Bird - технически они могут быть одним и тем же компонентом, но мне нравится держать вещи отдельно.
На этом этапе у нас есть красный квадрат, свободно падающий в зеленый прямоугольник. Давайте реализуем что-нибудь поинтереснее. Если пользователь нажимает где-нибудь на экране, мы хотим, чтобы наша птица на мгновение изменила направление и начала подпрыгивать вверх.
Для этого нам нужно изменить 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.10});
});
Matter.Engine.update(engine, time.delta);
return entities;
};
export default Physics;
Хорошо, что здесь происходит?
- RNGE удобно передает все события касания каждой из систем, переданных в движок.
- Поскольку нас интересуют только события «прессы» (касания), нам необходимо отфильтровать эти события.
- Каждый раз, когда мы сталкиваемся с постукиванием, мы прикладываем силу к центру нашей птицы с -0,10 (немного вверх).

Вещи складываются хорошо. Добавим препятствий!
В Flappy Bird препятствия представлены трубами. Один сверху и один снизу с постоянным зазором между ними. Сначала я добавлю на экран всего два набора труб (всего четыре штуки). Один в правой части экрана (Constants.MAX_WIDTH-Constants.PIPE_WIDTH / 2) и еще на один экран вправо (Constants.MAX_WIDTH * 2-(Constants.PIPE_WIDTH / 2)
Затем мы перемещаем эти части трубы на один пиксель влево на каждом тике. Если набор каналов выходит за пределы экрана, мы толкаем их на один экран вправо.
Положение канала по оси Y должно быть случайным, поэтому мы определяем две функции в App.js (но за пределами определения компонента, так как позже они могут быть повторно использованы).
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;
}
Все это на самом деле создает массив из двух чисел: одно для высоты верхней трубы, другое для нижней трубы.
Теперь мы можем обновить наш setupWorld метод:
setupWorld = () => {
let engine = Matter.Engine.create({ enableSleeping: false });
let world = engine.world;
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 });
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 });
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(world, [bird, floor, ceiling, pipe1, pipe2, pipe3, pipe4]);
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 }
}
}
Давайте обновим 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.10});
});
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;
Как я уже сказал, при каждом тике мы перемещаем трубы на 1 пиксель влево. Если труба скрывается из виду, мы перемещаем ее вправо, давая пользователю ощущение бесконечного количества труб.

Это все хорошо, но не совсем сложно, учитывая, что игрок не может проиграть. Добавить обнаружение столкновений с Matter.js просто.
В нашем методе setupWorld добавьте эти строки
Matter.Events.on(engine, 'collisionStart', (event) => {
var pairs = event.pairs;
this.gameEngine.dispatch({ type: "game-over"});
});
Это устанавливает прослушиватель событий, который будет запускаться при событии «collisionStart», испускаемом Matter. Слушатель сгенерирует другое событие с типом game-over, используя метод отправки RNGE.
При этом все, что осталось сделать, - это прослушать это событие и отобразить экран завершения игры, если пользователь потерпит неудачу. Вы можете слушать события RNGE, добавив onEvent опору в свой ‹GameEngine›
Ваш последний файл App.js должен выглядеть примерно так:
import React, { Component } from 'react';
import { Dimensions, StyleSheet, Text, View, StatusBar, Alert, TouchableOpacity } from 'react-native';
import Matter from "matter-js";
import { GameEngine } from "react-native-game-engine";
import Bird from './Bird';
import Wall from './Wall';
import Physics from './Physics';
import Constants from './Constants';
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();
}
setupWorld = () => {
let engine = Matter.Engine.create({ enableSleeping: false });
let world = engine.world;
world.gravity.y = 1.2;
let bird = Matter.Bodies.rectangle( Constants.MAX_WIDTH / 4, Constants.MAX_HEIGHT / 2, 50, 50);
bird.restitution = 20;
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 });
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 });
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(world, [bird, floor, ceiling, pipe1, pipe2, pipe3, pipe4]);
Matter.Events.on(engine, 'collisionStart', (event) => {
var pairs = event.pairs;
this.gameEngine.dispatch({ type: "game-over"});
});
return {
physics: { engine: engine, world: world },
floor: { body: floor, size: [Constants.MAX_WIDTH, 50], color: "green", renderer: Wall },
ceiling: { body: ceiling, size: [Constants.MAX_WIDTH, 50], color: "green", renderer: Wall },
bird: { body: bird, size: [50, 50], color: 'red', renderer: Bird},
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"){
//Alert.alert("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}
systems={[Physics]}
running={this.state.running}
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}>Game Over</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
}
});

Вот и все: уродливый, почти не работающий клон летучей птицы.
Пара «дел», которые нужно сделать, чтобы все стало лучше (если вы к этому готовы):
- В настоящее время трубы повторяются. Их положение Y должно быть легко менять каждый раз, когда мы отправляем их обратно в правую часть экрана.
- Птица должна немного вращаться вверх / вниз в зависимости от направления, в котором она движется.
- Некоторые изображения сделают вещи немного более захватывающими (без обид на красные квадраты)
Пожалуйста, не стесняйтесь отправить PR / форк репо, если вы чувствуете, что можете его улучшить.