분류 Reactjs

react-native-game-engine으로 뱀 만들기

컨텐츠 정보

  • 조회 893 (작성일 )

본문

TL; DR # 1 : 증오심? 비디오 시청 (콘텐츠가 마음에 들면 구독하십시오)


TL; DR # 2 : 코드를 원하십니까? 여기를 보세요 :


https://github.com/lepunk/react-native-videos/tree/master/Snake


스네이크는 90년대 노키아 폰이 대중적으로 만든 고전적인 비디오 게임입니다. 

게임 플레이는 간단합니다. 뱀의 머리를 제어하고 목표는 음식을 먹는 것입니다. 

음식을 먹을 때마다 뱀 꼬리가 자랍니다. (클래식 버전에서) 벽을 치거나 자신의 꼬리를 치면 죽습니다.


https://medium.com/@tamasszikszai/building-snake-with-react-native-game-engine-bbc8abfdebda 


저에게이 게임은 특히 향수가 있습니다. 이것은 고등학교 1 학년 동안 Turbo Pascal에서 성공적으로 복제한 첫 번째 게임이었기 때문입니다. 나는 심지어 반 친구에게 두 권의 사본을 팔았습니다. #이익


이 게임을 다시 만들기 위해 React Native : react-native-game-engine에서 현재 사용할 수 있는 게임 엔진만 사용하기로 결정했습니다.


엔진 자체는 매우 중요하지만 게임 개발에 필요한 기본 사항을 구현합니다.

  • 게임 루프
  • 엔티티 렌더링 시스템
  • 터치 이벤트를 처리합니다
  • 엔진과 사용자 정의 이벤트를 주고 받을 수 있습니다.

엔진 설치는 간단합니다 :


npm install --save react-native-game-engine 


엔진에는 두 가지를 정의해야 합니다.

  • 각 눈금에서 렌더링 될 엔티티 세트입니다.
  • 하나 이상의 "시스템". 이들은 기본적으로 각 틱에서 실행될 명령입니다.

우리의 엔터티 


뱀은 매우 간단합니다. 당신이 그것에 대해 생각한다면 우리가 구현해야 할 엔티티가 3 개 뿐입니다


머리 


"헤드"는 플레이어가 제어하는 ​​캐릭터가 됩니다. xspeed, yspeed, 위치 및 크기 속성을 갖습니다. 

우리의 응용 프로그램에서 크기는 일정합니다. position은 [x, y] 좌표 목록입니다. 

xspeed 및 yspeed는 1, 0 또는 -1의 값을 사용할 수 있습니다. 

xspeed가 1 또는 -1이면 yspeed는 0이어야 하고 마찬가지로 yspeed가 1 또는 -1이면 xspeed는 0이어야 합니다 (뱀은 대각선으로 갈 수 없음). 

<Head /> 컴포넌트에서 Head 렌더링을 구현할 것입니다


꼬리 


테일은 화면에서 우리의 머리를 따르는 블록 목록을 나타냅니다. 

이 꼬리는 머리가 점점 더 많은 음식을 먹으면서 자랄 것입니다. 

크기 소품 (상수 임)과 요소 소품 (처음에는 비어있는 목록)이 있으며 헤드가 음식을 먹을 때 [x, y] 좌표를 계속 추가합니다.


음식 


우리의 음식 실체는 무작위로 경기장에 배치되며 헤드가 충돌 할 때까지 그대로 유지됩니다. 

일단 발생하면 다른 임의의 위치로 다시 렌더링 해야 합니다. [x, y] 좌표 목록이 될 위치 소품과 크기가 일정하게 유지됩니다.


해보자 


내 앱의 Constants.js 파일에 상수를 정의하고 싶습니다.


import { Dimensions } from 'react-native';
export default Constants = {
MAX_WIDTH: Dimensions.get("screen").width,
MAX_HEIGHT: Dimensions.get("screen").height,
GRID_SIZE: 15,
CELL_SIZE: 20
}


그런 다음 기본 장면과 일부 컨트롤 버튼으로 index.js를 설정하겠습니다.


import React, { Component } from "react";
import { AppRegistry, StyleSheet, StatusBar, SafeAreaView, View, Alert, Button, TouchableOpacity } from "react-native";
import { GameEngine, dispatch } from "react-native-game-engine";
import { Head } from "./head";
import { Food } from "./food";
import { Tail } from "./tail";
import { GameLoop } from "./systems";
import Constants from './Constants';
export default class SnakeApp extends Component {
constructor(props) {
super(props);
this.boardSize = Constants.GRID_SIZE * Constants.CELL_SIZE;
this.engine = null;
this.state = {
running: true
}
}
randomBetween = (min, max) => {
return Math.floor(Math.random() * (max - min + 1) + min);
}
onEvent = (e) => {
if (e.type === "game-over"){
this.setState({
running: false
});
Alert.alert("Game Over");
}
}
reset = () => {
this.engine.swap({
1: { position: [0, 0], xspeed: 1, yspeed: 0, nextMove: 10, updateFrequency: 10, size: 20, renderer: <Head />},
2: { position: [this.randomBetween(0, Constants.GRID_SIZE - 1), this.randomBetween(0, Constants.GRID_SIZE - 1)], size: 20, renderer: <Food />},
3: { size: 20, elements: [], renderer: <Tail /> }
});
this.setState({
running: true
});
}
render() {
return (
<View style={styles.container}>
<GameEngine
ref={(ref) => { this.engine = ref; }}
style={[{ width: this.boardSize, height: this.boardSize, backgroundColor: '#ffffff', flex: null }]}
systems={[ GameLoop ]}
entities={{
head: { position: [0, 0], xspeed: 1, yspeed: 0, nextMove: 10, updateFrequency: 10, size: 20, renderer: <Head />},
food: { position: [this.randomBetween(0, Constants.GRID_SIZE - 1), this.randomBetween(0, Constants.GRID_SIZE - 1)], size: 20, renderer: <Food />},
tail: { size: 20, elements: [], renderer: <Tail /> }
}}
running={this.state.running}
onEvent={this.onEvent}>
<StatusBar hidden={true} />
</GameEngine>
<Button title="New Game" onPress={this.reset} />
<View style={styles.controls}>
<View style={styles.controlRow}>
<TouchableOpacity onPress={() => { this.engine.dispatch({ type: "move-up" })} }>
<View style={styles.control} />
</TouchableOpacity>
</View>
<View style={styles.controlRow}>
<TouchableOpacity onPress={() => { this.engine.dispatch({ type: "move-left" })} }>
<View style={styles.control} />
</TouchableOpacity>
<View style={[styles.control, { backgroundColor: null}]} />
<TouchableOpacity onPress={() => { this.engine.dispatch({ type: "move-right" })}}>
<View style={styles.control} />
</TouchableOpacity>
</View>
<View style={styles.controlRow}>
<TouchableOpacity onPress={() => { this.engine.dispatch({ type: "move-down" })} }>
<View style={styles.control} />
</TouchableOpacity>
</View>
</View>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000000',
alignItems: 'center',
justifyContent: 'center'
},
controls: {
width: 300,
height: 300,
flexDirection: 'column',
},
controlRow: {
height: 100,
width: 300,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row'
},
control: {
width: 100,
height: 100,
backgroundColor: 'blue'
}
});
AppRegistry.registerComponent("Snake", () => SnakeApp);


좋아, 아직 논의하지 않은 몇 가지 사항 :

  1. Running state 변수를 사용하고 있으며 이를 Prop로서 GameEngine에 전달합니다. 사용자가 죽으면 게임 루프를 멈추는 것이 매우 유용합니다.
  2. this.engine 인스턴스 변수에서 GameEngine에 대한 참조를 유지합니다. 이것을 사용하여 GameEngine에서 특정 메소드를 호출합니다
  3. onEvent 이벤트 핸들러를 GameEngine에 전달합니다. 이 메소드는 GameEngine.dispatch를 사용하여 새로운 이벤트가 전달 될 때마다 실행됩니다. 디스패치는 메인 앱과 게임 루프간에 통신 할 수 있는 방법입니다. 우리는 다른 유형의 이벤트를 방출 할 것입니다.
  4. 헤드 엔티티에서 다음 소품을 확인하십시오 : nextMove : 10, updateFrequency : 10 기본 게임 루프는 초당 60 프레임에 가깝습니다. 모든 틱에서 헤드 위치를 업데이트하고 싶지 않으므로 nextMove 및 updateFrequency를 사용하여 업데이트 속도를 늦 춥니 다. 이것에 대해서는 나중에 더 설명하겠습니다.
  5. 화면 컨트롤에 일부를 추가했습니다. 각각의 시스템은 "move- *"유형의 이벤트를 생성하여 시스템에서 이에 따라 헤드의 x 및 yspeed를 듣고 변경합니다.

이 점 외에도 코드가 매우 자명하다고 생각합니다.


Rendering 


엔티티를 렌더링 하는 것은 매우 간단합니다. <Head />, <Food /> 및 <Tail />에 대한 구성 요소만 작성하면 됩니다.


머리와 음식은 매우 비슷합니다. 그것들은 색상에 따라 다른 소품을 가진 동일한 구성 요소 일 수 있습니다. 그러나 지금은 간단하게 유지합시다.


import React, { Component } from "react";
import { StyleSheet, View } from "react-native";
class Head extends Component {
constructor(props){
super(props);
}
render() {
const x = this.props.position[0];
const y = this.props.position[1];
return (
<View style={[styles.finger, { width: this.props.size, height: this.props.size, left: x * this.props.size, top: y * this.props.size }]} />
);
}
}
const styles = StyleSheet.create({
finger: {
backgroundColor: '#888888',
position: "absolute"
}
});
export { Head };


import React, { Component } from "react";
import { StyleSheet, View } from "react-native";
class Food extends Component {
constructor(props){
super(props);
}
render() {
const x = this.props.position[0];
const y = this.props.position[1];
return (
<View style={[styles.finger, { width: this.props.size, height: this.props.size, left: x * this.props.size, top: y * this.props.size }]} />
);
}
}
const styles = StyleSheet.create({
finger: {
backgroundColor: 'purple',
position: "absolute"
}
});
export { Food };


그들이 하는 것은 지정된 x와 y 좌표에서 정사각형을 렌더링 하는 것입니다. 로켓 과학이 아닙니다.


꼬리는 여러 블록을 렌더링 해야 하기 때문에 약간 더 복잡하지만 설명이 너무 필요하다고 생각하지 않습니다.


import React, { Component } from "react";
import { StyleSheet, View } from "react-native";
import Constants from './Constants';
class Tail extends Component {
constructor(props){
super(props);
}
render() {
let tailList = this.props.elements.map((el, idx) => {
return <View key={idx} style={{ width: this.props.size, height: this.props.size, position: 'absolute', left: el[0] * this.props.size, top: el[1] * this.props.size, backgroundColor: 'blue' }} />
});
return (
<View style={{ width: Constants.GRID_SIZE * this.props.size, height: Constants.GRID_SIZE * this.props.size }}>
{tailList}
</View>
);
}
}
const styles = StyleSheet.create({
finger: {
backgroundColor: '#888888',
position: "absolute"
}
});
export { Tail };


그리고 이것으로 엔티티를 렌더링 하는 데 필요한 모든 것


GameLoop 


react-native-game-engine을 사용하면 여러 "시스템"을 정의 할 수 있습니다. 이 시스템은 유용한 매개 변수 세트와 함께 모든 "틱"에서 호출됩니다. 그들은 본질적으로 게임의 논리를 구현하고 있습니다.


항상 엔티티 목록을 수신하고 선택적으로 터치, 이벤트 등을 수신 할 수 있습니다.


우리의 경우에는 하나의 시스템 만 갖게 되며 이를 GameLoop이라고 합니다.


가장 최소한의 구현은 다음과 같습니다.


import React, { Component } from "react";
import Constants from './Constants';
const GameLoop = (entities, { touches, dispatch, events }) => {
let head = entities.head;
let food = entities.food;
let tail = entities.tail;
return entities;
};
export { GameLoop };


시원하고 시원하고 시원하지만 아무 것도 하지 않습니다. 뱀을 움직여 봅시다


import React, { Component } from "react";
import Constants from './Constants';
const GameLoop = (entities, { touches, dispatch, events }) => {
let head = entities.head;
let food = entities.food;
let tail = entities.tail;
head.nextMove -= 1;
if (head.nextMove === 0){
head.nextMove = head.updateFrequency;
if (
head.position[0] + head.xspeed < 0 ||
head.position[0] + head.xspeed >= Constants.GRID_SIZE ||
head.position[1] + head.yspeed < 0 ||
head.position[1] + head.yspeed >= Constants.GRID_SIZE
) {
// snake hits the wall
dispatch({ type: "game-over" })
} else {
// snake moves
head.position[0] += head.xspeed;
head.position[1] += head.yspeed;
}
}
return entities;
};
export { GameLoop };


좋아, 이것을 풀자 :


  • nextMove = 10 및 updateFrequency = 10을 Head 엔터티에 전달한 방법을 기억하십니까? 각 틱마다 nextMove를 하나씩 줄입니다. 0에 도달하면 환경에서 일부 계산을 수행합니다. 이런 식으로 게임은 10 틱마다 움직입니다.
  • 먼저 헤드가 재생 그리드 밖으로 이동했는지 확인합니다. 그렇다면 "game-over"유형의 이벤트를 전달합니다. index.js의 onEvent 메소드는 이 이벤트를 듣고 게임을 중지합니다.
  • 헤드가 재생 그리드 안에 있으면 xspeed와 yspeed로 위치를 업데이트합니다.

놀랍습니다. 움직이는 것이 있지만 제어 할 수 없습니다. 수정 해 봅시다 :


import React, { Component } from "react";
import Constants from './Constants';
const GameLoop = (entities, { touches, dispatch, events }) => {
let head = entities.head;
let food = entities.food;
let tail = entities.tail;
if (events.length){
for(let i=0; i<events.length; i++){
if (events[i].type === "move-down" && head.yspeed != -1){
head.yspeed = 1;
head.xspeed = 0;
} else if (events[i].type === "move-up" && head.yspeed != 1){
head.yspeed = -1;
head.xspeed = 0;
} else if (events[i].type === "move-left" && head.xspeed != 1){
head.yspeed = 0;
head.xspeed = -1;
} else if (events[i].type === "move-right" && head.xspeed != -1){
head.yspeed = 0;
head.xspeed = 1;
}
}
}
head.nextMove -= 1;
if (head.nextMove === 0){
head.nextMove = head.updateFrequency;
if (
head.position[0] + head.xspeed < 0 ||
head.position[0] + head.xspeed >= Constants.GRID_SIZE ||
head.position[1] + head.yspeed < 0 ||
head.position[1] + head.yspeed >= Constants.GRID_SIZE
) {
// snake hits the wall
dispatch({ type: "game-over" })
} else {
// snake moves
head.position[0] += head.xspeed;
head.position[1] += head.yspeed;
}
}
return entities;
};
export { GameLoop };


괜찮아. 최종 사용자가 컨트롤 버튼 중 하나를 누를 때마다 어떻게 "move- *"유형 이벤트를 전달했는지 기억하십니까? 파견 된 이벤트는 게임 루프로 전달됩니다. 사용자 입력에 따라 x 및 yspeed를 변경하기 만하면 헤드가 마법의 방향을 변경합니다.


음식을 먹을 시간 :


import React, { Component } from "react";
import Constants from './Constants';
const randomBetween = (min, max) => {
return Math.floor(Math.random() * (max - min + 1) + min);
}
const GameLoop = (entities, { touches, dispatch, events }) => {
let head = entities.head;
let food = entities.food;
let tail = entities.tail;
if (events.length){
for(let i=0; i<events.length; i++){
if (events[i].type === "move-down" && head.yspeed != -1){
head.yspeed = 1;
head.xspeed = 0;
} else if (events[i].type === "move-up" && head.yspeed != 1){
head.yspeed = -1;
head.xspeed = 0;
} else if (events[i].type === "move-left" && head.xspeed != 1){
head.yspeed = 0;
head.xspeed = -1;
} else if (events[i].type === "move-right" && head.xspeed != -1){
head.yspeed = 0;
head.xspeed = 1;
}
}
}
head.nextMove -= 1;
if (head.nextMove === 0){
head.nextMove = head.updateFrequency;
if (
head.position[0] + head.xspeed < 0 ||
head.position[0] + head.xspeed >= Constants.GRID_SIZE ||
head.position[1] + head.yspeed < 0 ||
head.position[1] + head.yspeed >= Constants.GRID_SIZE
) {
// snake hits the wall
dispatch({ type: "game-over" })
} else {
// snake moves
head.position[0] += head.xspeed;
head.position[1] += head.yspeed;
if (head.position[0] === food.position[0] && head.position[1] === food.position[1]){
// eating Food
tail.elements = [[food.position[0], food.position[1]]].concat(tail.elements);
food.position[0] = randomBetween(0, Constants.GRID_SIZE - 1);
food.position[1] = randomBetween(0, Constants.GRID_SIZE - 1);
}
}
}
return entities;
};
export { GameLoop };


이 단계에서는 헤드 위치가 음식 위치와 같은지 확인합니다. 그렇다면 우리는 두 가지 일을 하고 있습니다 :


  • 음식의 위치를 ​​꼬리 요소 앞에 붙입니다.
  • 우리 음식에 대한 새로운 임의의 위치를 ​​생성하여 새로운 위치에 팝업 됩니다.

놀랄 만한. 우리의 꼬리를 제외하고는 우리 머리를 따르지 않습니다. 또한 Head가 Tail 요소 중 하나에 도달하면 게임이 끝났는지 확인해야 합니다.


import React, { Component } from "react";
import Constants from './Constants';
const randomBetween = (min, max) => {
return Math.floor(Math.random() * (max - min + 1) + min);
}
const GameLoop = (entities, { touches, dispatch, events }) => {
let head = entities.head;
let food = entities.food;
let tail = entities.tail;
if (events.length){
for(let i=0; i<events.length; i++){
if (events[i].type === "move-down" && head.yspeed != -1){
head.yspeed = 1;
head.xspeed = 0;
} else if (events[i].type === "move-up" && head.yspeed != 1){
head.yspeed = -1;
head.xspeed = 0;
} else if (events[i].type === "move-left" && head.xspeed != 1){
head.yspeed = 0;
head.xspeed = -1;
} else if (events[i].type === "move-right" && head.xspeed != -1){
head.yspeed = 0;
head.xspeed = 1;
}
}
}
/*
// Want swipe controls? Uncomment these and comment the above block
touches.filter(t => t.type === "move").forEach(t => {
if (head && head.position) {
if (t.delta.pageY && t.delta.pageX){
if (t.delta.pageY && Math.abs(t.delta.pageY) > Math.abs(t.delta.pageX)){
if (t.delta.pageY < 0 && head.yspeed != 1){
head.yspeed = -1;
head.xspeed = 0;
} else if (t.delta.pageY > 0 && head.yspeed != -1) {
head.yspeed = 1;
head.xspeed = 0;
}
} else if (t.delta.pageX) {
if (t.delta.pageX < 0 && head.xspeed != 1){
head.xspeed = -1;
head.yspeed = 0;
} else if (t.delta.pageX > 0 && head.xspeed != -1) {
head.xspeed = 1;
head.yspeed = 0;
}
}
}
}
});
*/
head.nextMove -= 1;
if (head.nextMove === 0){
head.nextMove = head.updateFrequency;
if (
head.position[0] + head.xspeed < 0 ||
head.position[0] + head.xspeed >= Constants.GRID_SIZE ||
head.position[1] + head.yspeed < 0 ||
head.position[1] + head.yspeed >= Constants.GRID_SIZE
) {
// snake hits the wall
dispatch({ type: "game-over" })
} else {
// move the tail
let newTail = [[head.position[0], head.position[1]]];
tail.elements = newTail.concat(tail.elements).slice(0, -1);
// snake moves
head.position[0] += head.xspeed;
head.position[1] += head.yspeed;
// check if it hits the tail
for(let i=0; i<tail.elements.length; i++){
if (tail.elements[i][0] === head.position[0] && tail.elements[i][1] === head.position[1]){
dispatch({ type: "game-over" })
}
}
if (head.position[0] === food.position[0] && head.position[1] === food.position[1]){
// eating Food
tail.elements = [[food.position[0], food.position[1]]].concat(tail.elements);
food.position[0] = randomBetween(0, Constants.GRID_SIZE - 1);
food.position[1] = randomBetween(0, Constants.GRID_SIZE - 1);
}
}
}
return entities;
};
export { GameLoop };


여기서 하는 일은 단순히 현재 헤드의 위치를 ​​꼬리 요소 앞에 붙이고 꼬리의 마지막 요소를 제거하는 것입니다. 그런 다음 간단한 for 루프를 사용하여 헤드가 테일의 요소와 충돌하는지 확인합니다. 그렇다면 게임 오버 이벤트를 발송합니다.


1*mO_UzzKQhFVkKrjJuMkS5Q.gif 


스와이프 컨트롤을 원하는 경우 주석 처리 된 코드 블록도 추가했습니다. 내 원래 아이디어는 이것을 제어 역학으로 사용하는 것이었지만 실제 장치에서 매우 기이하게 느껴져서 그것에 대해 결정했습니다.


그게 다야! 물론 이것은 해킹일 뿐이며 크게 개선 될 수 있습니다. https://github.com/lepunk/react-native-videos/tree/master/Snake : PR을 보내거나 GitHub 저장소를 포크하십시오.