React Native ゲーム開発 Whack-A-Mole(もぐらたたき) 第三回

## Whack-A-Mole(もぐらたたき) 第三回

フロントエンジニアの、YAMATAKUです。
[前回](https://qiita.com/team-lot/items/8f871e9d82256bd828d6)、もぐらの表示&アニメーションの実装まで行いました。今回は、ゲームを仕上げていきたいと思います。

まずは、もぐらクラスのすべてのアクションを実装していきます。

## もぐらクラス修正

```jsx:Mole.js

// 省略 //

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

    this.mole = null;           //もぐら本体
    this.actionTimeout = null;  //アニメーション用
    this.isPopping = false;     //飛び出る
    this.isFeisty = false;      //気力
    this.isHealing = false;     //回復もぐら
    this.isWhacked = false;     //叩かれた
    this.isAttacking = false;   //攻撃した
  }

  /**
    * 飛び出る
    * @return void
   */
  pop = () => {
    this.isWhacked = false;
    this.isPopping = true;
    this.isAttacking = false;

    //40%：攻撃あり、57%：表示だけ、3%：回復もぐら
    this.isFeisty = Math.random() < 0.6;
    if (!this.isFeisty) {
      this.isHealing = Math.random() < 0.05;
    }

    if (this.isHealing) {
      this.mole.play({  //playはSpriteSheetのアニメ関数
        type: "heal",   //回復もぐら
        fps: 24,
        onFinish: () => {
          this.actionTimeout = setTimeout(() => {
            this.mole.play({
              type: "hide",
              fps: 24,
              onFinish: () => {
                this.isPopping = false;
                this.props.onFinishPopping(this.props.index);
              }
            })
          }, 1000);
        }
      })
    } else {
      this.mole.play({
        type: "appear",
        fps: 24,
        onFinish: () => {
          if(this.isFeisty) {   //もぐらの攻撃
            this.actionTimeout = setTimeout(() => {
              this.isAttacking = true;
              this.props.onDamage();
              this.mole.play({
                type: "attack",
                fps: 12,
                onFinish: () => {
                  this.mole.play({
                    type: "hide",
                    fps: 24,
                    onFinish: () => {
                      this.isPopping = false;
                      this.props.onFinishPopping(this.props.index);
                    }
                  })
                }
              })
            }, 1000);
          } else {
            this.actionTimeout = setTimeout(() => {
              this.mole.play({
                type: "hide",
                fps: 24,
                onFinish: () => {
                  this.isPopping= false;
                  this.props.onFinishPopping(this.props.index);
                }
              })
            }, 1000);
          }
        }
      })
    }
  }

  /**
   * 叩く
   * @return void
   */
  whack = () => {
        console.log(`wack1 ${this.isPopping} ${this.isWhacked} ${this.isAttacking}`);

    if (!this.isPopping || this.isWhacked || this.isAttacking) {  //非表示 or 叩かれてる or 攻撃してきた 場合はreturn
      return;
    }

    console.log('wack2');

    //やっつけたので、以降のもぐらアニメーションは無効に
    if (this.actionTimeout) {
      clearTimeout(this.actionTimeout);
    }

    this.isWhacked = true;    //叩かれたフラグ、連続押し防止
    this.isFeisty = false;    //念のため

    this.props.onScore();
    if (this.isHealing) {
      this.props.onHeal();
    }

    this.mole.play({
      type: "dizzy",    //めまい
      fps: 24,
      onFinish: () => {
        this.mole.play({
          type: "faint",    //気絶、失神いう意味でフェイント(feint)ではない
          fps: 24,
          onFinish: () => {
            this.isPopping = false;
            this.props.onFinishPopping(this.props.index);
          }
        })
      }
    })
  }

  render() {
    // 省略 //
  }
} 

```

# ゲーム仕様

唐突ですが、今回のゲームは以下の様な仕様になっています。

- もぐらは、何もしないで穴に戻る/攻撃してくる/回復もぐらの3種類
- 1ステージ5秒間(ステージが進むごとにレベル表示が1上がる)
- プレイヤーはHP(Healthメーター)を持ち、もぐらの攻撃によりダメージをうける
- もぐらが攻撃するまえにタッチすれば攻撃は回避できる
- 1ステージ5秒以内にHPが0にならなければクリア(HPは次のステージに引き継ぐ)
- ステージが進むたびに、もぐらの出現スピードが早くなる
- ステージ上限なし

## クリア/ゲームオーバー/一時停止 画面実装

前述仕様において、以下画面を実装します。

- クリア画面
- ゲームオーバー画面
- 一時停止画面

を用意します。

まずは、各クラスのスタイルをまとめたクラスを作成します。

```jsx:PopupStyles.js
import { StyleSheet } from 'react-native';
import Constants from './Constants';

export default styles = StyleSheet.create({
  clearScreen: {
    position: 'absolute',
    top: 0,
    left: 0,
    width: Constants.MAX_WIDTH,
    height: Constants.MAX_HEIGHT,
    backgroundColor: 'rgba(255, 255, 255, 0.5)',
    justifyContent: 'center',
    alignItems: 'center',
    flexDirection: 'column',
  },
  clearedLevelContainer: {
    width: Constants.YR * 250,
    height: Constants.YR * 250,
    borderRadius: Constants.YR * 125,
    backgroundColor: '#ff1a1a',
    borderWidth: 5,
    borderColor: 'white',
    alignItems: 'center',
    justifyContent: 'center',
    flexDirection: 'column',
  },
  clearedLevelText: {
    fontSize: 45,
    color: 'white',
    fontFamily: Constants.FONT_LITITAONE_REGULAR,
  },
  panel: {
    backgroundColor: '#29aecc',
    borderColor: 'white',
    borderWidth: 5,
    borderRadius: 10,
    alignItems: 'center',
    justifyContent: 'center',
    width: Constants.YR * 350,
    height: Constants.YR * 200,
    marginTop: Constants.YR * -40,
  },
  panelTitle: {
    fontSize: 45,
    color: 'black',
    fontFamily: Constants.FONT_LITITAONE_REGULAR,
    textShadowColor: 'white',
    textShadowOffset: { width: 1, height: 1},
    textShadowRadius: 2,
  },
  panelText: {
    fontSize: 31,
    color: 'white',
    fontFamily: Constants.FONT_LITITAONE_REGULAR,
    textShadowColor: 'black',
    textShadowOffset: { width: 1, height: 1},
    textShadowRadius: 2,
    marginBottom: Constants.YR * 50,
  },
  panelButtonsContainer: {
    position: 'absolute',
    height: Constants.YR * 80,
    bottom: Constants.YR *  -40,
    alignItems: 'center',
    justifyContent: 'center',
    width: Constants.YR * 350,
    flexDirection: 'row'
  },
  panelButton: {
    width: Constants.YR * 80,
    height: Constants.YR * 80,
    borderRadius: Constants.YR * 40,
    backgroundColor: '#ff1a1a',
    borderWidth: 5,
    borderColor: 'white',
    marginLeft: Constants.XR * 15,
    marginRight: Constants.XR * 15,
    alignItems: 'center',
    justifyContent: 'center',
  },
  panelButtonIcon: {
    width: Constants.YR * 35,
    height: Constants.YR * 35,
    alignItems: 'center',
    justifyContent: 'center',
  },
})
```


各画面を実装していきます。

- クリア画面

```jsx:Clear.js

import React, { Component } from 'react';
import { 
  View,
  Text,
  TouchableWithoutFeedback,
  Image
} from 'react-native';
import styles from './PopupStyles';

class Clear extends Component {
  render() {
    return (
      <View style={styles.clearScreen}>
        <View style={styles.clearedLevelContainer}>
          <Text style={styles.clearedLevelText}>Level</Text>
          <Text style={styles.clearedLevelText}>{this.props.level}</Text>
        </View>

        <View style={styles.panel}>
          <Text style={styles.panelTitle}>Cleared</Text>
          <Text style={styles.panelText}>Score:{this.props.score}</Text>

          <View style={styles.panelButtonsContainer}>
            <TouchableWithoutFeedback onPress={this.props.onReset}>
              <View style={styles.panelButton}>
                <Image style={styles.panelButtonIcon} resizeMode="contain" source={Images.restartIcon} />
              </View>
            </TouchableWithoutFeedback>
            <TouchableWithoutFeedback onPress={this.props.onNextLevel}>
              <View style={styles.panelButton}>
                <Image style={styles.panelButtonIcon} resizeMode="contain" source={Images.playIcon} />
              </View>
            </TouchableWithoutFeedback>
          </View>
        </View>
      </View>
    );
  }
}
export default Clear;
```

- ゲームオーバー画面

```jsx:GameOver.js
import React, { Component } from 'react';
import { 
  View,
  Text,
  TouchableWithoutFeedback,
  Image
 } from 'react-native';
 import styles from './PopupStyles';

 export default class GameOver extends Component {
   render() {
      return (
        <View style={styles.clearScreen}>
          <View style={styles.clearedLevelContainer}>
            <Text style={styles.clearedLevelText}>Level</Text>
            <Text style={styles.clearedLevelText}>{this.props.level}</Text>
          </View>

          <View style={styles.panel}>
            <Text style={styles.panelTitle}>Game Over</Text>
            <Text style={styles.panelText}>Score:{this.props.score}</Text>
          
            <View style={styles.panelButtonsContainer}>
              <TouchableWithoutFeedback onPress={this.props.onReset}>
                <View style={styles.panelButton} >
                  <Image style={styles.panelButtonIcon} resizeMode="contain" source={Images.restartIcon} />
                </View>
              </TouchableWithoutFeedback>
            </View>
          </View>
       </View>
     );
   }
 };
```
- 一時停止画面

```jsx:Pause.js
import React, { Component } from 'react';
import { 
  View, 
  Text,
  TouchableWithoutFeedback,
  Image
} from 'react-native';
import styles from './PopupStyles';

class Pause extends Component {
  render() {
    return (
      <View style={styles.clearScreen}>
        <View style={styles.panel}>
          <Text style={styles.panelTitle}>Ready?</Text>

          <View style={styles.panelButtonsContainer}>
            <TouchableWithoutFeedback onPress={this.props.onReset}>
              <View style={styles.panelButton}>
                <Image style={styles.panelButtonIcon} resizeMode="contain" source={Images.restartIcon} />
              </View>
            </TouchableWithoutFeedback>
            <TouchableWithoutFeedback onPress={this.props.onResume}>
              <View style={styles.panelButton}>
                <Image style={styles.panelButtonIcon} resizeMode="contain" source={Images.playIcon} />
              </View>
            </TouchableWithoutFeedback>
          </View>
        </View>
      </View>
    );
  }
}

export default Pause;
```

GameOver.jsだけ、「export default class GameOver」と定義していますが、他2クラスのように、ソース末尾に「export default クラス名」としても、問題ありません。ここで"export default"したクラスは、他のクラスで、

(例)GameOver.jsを呼び出す場合

```
import GameOver from './GameOver';
```
のように呼び出すことができます。

## 仕上げ

App.jsに、今回作成したクラスを実装していきます。

```jsx:App.js

import React, { Component } from "react";
import { 
    // 追加
    TouchableWithoutFeedback,
    Button,
} from "react-native";

// 追加
import SpriteSheet from 'rn-sprite-sheet';
import GameOver from './GameOver';
import Clear from './Clear';
import Pause from './Pause';


const DEFAULT_TIME = 5;   //1ステージ5秒に設定

// 省略

export default class MainScreen extends Component {
  constructor(props){
    super(props);
    this.state = DEFAULT_STATE;
    this.moles = [];
    this.molesPopping = 0;
    this.interval = null;
    this.timeInterval = null;
  }


//省略

  reset = () => {
    this.molesPopping = 0;

    this.setState(DEFAULT_STATE, this.setupTicks);
  }

  pause = () => {
    console.log('pause1');
    if (this.interval) clearInterval(this.interval);
    if (this.timeInterval) clearInterval(this.timeInterval);
    
    console.log('pause2');

    this.setState({
      paused: true
    });
  }

  resume = () => {
    this.molesPopping = 0;
    this.setState({
      paused: false
    }, this.setupTicks);
  }

  nextLevel = () => {
    this.molesPopping = 0;

    this.setState({
      level: this.state.level + 1,
      cleared: false,
      gameover: false,
      time: DEFAULT_TIME
    }, this.setupTicks)
  }
  

  onScore = () => {
    this.setState({
      score: this.state.score + 1
    })
  }

  onDamage = () => {
    if(this.state.cleared || this.state.gameOver || this.state.paused) {
      return;
    }

    let targetHealth = this.state.health - 10 < 0 ? 0 : this.state.health - 10;

    this.setState({
      health: targetHealth
    });

    if (targetHealth <= 0) {
      this.gameOver();
    }
  }

  gameOver = () => {
    clearInterval(this.interval);
    clearInterval(this.timeInterval);

    this.setState({
      gameover: true
    });
  }

  onHeal = () => {
    let targetHealth = this.state.health + 10 > 50 ? 50 : this.state.health + 10;
    this.setState({
      health: targetHealth
    });
  }

  render() {
    // 省略
    return (
      <View style={styles.container}>
        
       //省略        

        {this.state.cleared && <Clear onReset={this.reset} onNextLevel={this.nextLevel} level={this.state.level} score={this.state.score} />}
        {this.state.gameover && <GameOver onReset={this.reset} level={this.state.level} score={this.state.score} />}
        {this.state.paused && <Pause onReset={this.reset} onResume={this.resume} />}
      </View> 
    )
  }
 }

const styles = StyleSheet.create({
  // 省略
})

```

これで実装が完了です。以下のような感じのゲームになります。
![タイトルなし.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/616010/36a822cb-d5bf-bda6-4b53-226c46346780.gif)

## まとめ

今回のチュートリアルで、ReactNativeでゲームを作る上での、お作法を学べた気がします。
以下にソースをアップしました。

https://github.com/TEAM-LOT/WhackAMole

次回からは、スマホゲームの火付け役「FlappyBird」のゲームサンプルを学習していきたいと思います。

参考：
https://github.com/lepunk/react-native-videos/tree/master/FlappyBird