Firebaseで流しそうめん

これは[Firebase #2 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/firebase_2)の20日目の記事です。

# はじめに
Firebase Realtime Databaseでスマホを連結して、スマホの中で流しそうめんします。
流れてくるそうめんは、箸デバイス(おそらく世界初)でとれます。
箸デバイスとモードの背景は、[@krohigewagma](https://qiita.com/krohigewagma)さんに作ってもらいました。

![流しそうめん展示.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543183/174f6b1a-7884-28c4-af7e-868da30939cc.png)

流しそうめんを作ろうと思ったのは、ダライアスが好きで、今ならスマホで作れるだろうと試作を始めたら、いつの間にか流しそうめんを作ってました。

# 概要
- 1台のそうめんを流す端末と、複数台のそうめんが流れる端末から構成されています。
- 流す端末は、定期的にそうめん情報をFirebase Realtime Databaseに追加します。
- 流れる端末は、そうめん情報をonChildChanged()で監視し、そうめんの位置が自身の端末番号と同じであれば、そうめんを描画して流します。
- 流れる端末の端末番号は、事前に登録しておきます。
- 制限時間内に、そうめん取り放題のゲームモードがあります。

![流しそうめん概要.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543183/8705f8cf-f799-fe24-effd-a97eb5390b7f.png)

- 展示会で実演した流しそうめん  
[流しそうめんの紹介動画](https://www.youtube.com/watch?v=__XAoHG-x54)  
展示会では子供が夢中でやってくれます。

## データ定義

- モード情報  
modeが背景と流す物の設定です。

``` kotlin
data class ModeInfo(
    var key:String = "",
    var mode:Int = 0,      // ステージモード種別
    var timePlay:Int = 20  // ゲームプレイ時間(秒)
)
```

- そうめん情報  
typeとrowは、流す端末がモード情報のmodeを元に決定します。

``` kotlin
data class FlowInfo(
    var key:String = "",
    var name:String = "",
    var type:Int = 0,             // アイテム種別(モード毎に設定)
    var pos:Int = 0,              // 現在の端末位置
    var row:Int = 0,              // 縦の表示位置(モード毎に設定)
    var complete:Boolean = false
)
```
## モード種類
- モードは流しそうめん、魚群(海)、寿司の3種類です。
- モード毎に流すもの・縦の表示位置・スピード・スコアを設定しています。

### 流しそうめん
![モードそうめん.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543183/05f7d2b6-a815-cd12-abf9-5742942a6df7.png)

### 魚群(海)
![モード魚群.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543183/1c5b8047-767f-008d-03cd-fb1d842b1a02.png)

### 寿司
![モード寿司.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543183/1863a412-7df4-382a-b1dd-5cb2b6d24d3b.png)

# そうめんが流れる仕組み
そうめんを流す端末と、そうめんが流れる端末の仕組みを説明します。

## そうめん追加(流す端末)
- timerで定期的にそうめんを流します。
- 最初にそうめん情報のposを0で追加します。
- 次にそうめん情報のposを1(最初の端末)に変更します。  
この手順を行うのは、そうめんが流れる端末がonChildChanged()のみ監視しているためです。

```kotlin
// そうめん情報参照を生成
var refFlow = database?.getReference("flow")

// そうめんをDatabaseへ追加
fun addEntity() {
    // ユニークKey取得
    val key = refFlow?.child("flow")?.push()?.getKey()!!

    // そうめん情報を設定
    val data = FlowInfo()
    data.key = key
    data.name = "そうめん"
    data.type = stageGame?.randomEntityType()!!  // 流す物をランダムに設定
    data.pos = 0
    data.row = 1  // モード毎に縦の位置を設定

    // Databaseへ追加
    refFlow?.child(data.key)?.setValue(data)

    // Databaseを更新
    val map = mutableMapOf<String, Any>("pos" to 1)
    refFlow?.child(data.key)?.updateChildren(map)
}

```

## そうめん取得(流れる端末)
- ChlidEventListenerのonChildChanged()でそうめん情報のposを監視します。posが端末番号と同じならば、そうめんを追加して再描画します。
- onChildAdded()でそうめん情報のposを監視しないのは、アプリ再起動後のDatabase同期でそうめんを追加すると、posが変更されずに留まってしまったそうめんも追加することになり、大量のそうめんで負荷かかるのを避けるためです。つまり、そうめんが落ちたか取られたか分からないけど、失くなったと判断します。(都合良すぎ)

```kotlin
val listenerChildEventFlow = object : ChildEventListener {
    override fun onCancelled(dataSnapshot: DatabaseError) { }

    override fun onChildMoved(dataSnapshot: DataSnapshot, previousChildName: String?) { }

    override fun onChildChanged(dataSnapshot: DataSnapshot, previousChildName: String?) {
        val value = dataSnapshot.getValue(FlowInfo::class.java)
        if (value != null) {
            // posが端末番号と同じならそうめんを追加&再描画
            if (AppGlobal.deviceNumber == value.pos) {
                stageGame?.addEntity(value)
                invalidate()
            }
        }
    }

    override fun onChildAdded(dataSnapshot: DataSnapshot, previousChildName: String?) { }

    override fun onChildRemoved(p0: DataSnapshot) { }
}
```

## 全体イメージ
- 流す端末からそうめんを追加します。
![詳細フロー1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543183/234165a9-eb7a-b5e4-a379-fb75b816e0b1.png)

- 1台目の端末がposの変化を検知して、そうめんを描画します。
![詳細フロー2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543183/e4584229-682b-fb07-bfa8-a4eb0ec7468c.png)

- 1台目の端末の右端にかかったら、posを2に変更します。
- 2台目の端末がposの変化を検知して、そうめんを描画します。
![詳細フロー3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543183/8dc412cf-80aa-4ad3-8b75-0f18aa48c266.png)

- これを繰り返します。

## モード切り替え(流す端末)
- 流す端末の起動時に、configにモードが存在するか確認し、存在すればモードのkeyを取得します。

```kotlin
// configのモード情報を生成
var refMode = database?.getReference("config")

// モード変更Listener登録(1回のみ取得)
refMode?.child("mode")?.addListenerForSingleValueEvent(listenerValueEventMode)

// 現在のモードを取得
val listenerValueEventMode = object : ValueEventListener {
    override fun onDataChange(dataSnapshot: DataSnapshot) {
        if (dataSnapshot.exists()) {
            val value = dataSnapshot.getValue(ModeInfo::class.java)
            if (value != null) {
                key_mode = value.key
                val mode = ModeStage.values().filter { it.mode == value.mode }.first()
                AppGlobal.modeStage = mode
            }
        }
    }

    override fun onCancelled(databaseError: DatabaseError) { }
}
```

- 流す端末へモード切り替えボタンを用意し、ボタンクリック時にモードを新規登録 or 変更します。

```kotlin
// モード設定
fun setMode(mode:Int) {
    if (key_mode.isEmpty()) {
        // 新規
        val data = ModeInfo()
        data.mode = mode
        // ユニークKey取得
        key_mode = refMode?.child("mode")?.push()?.getKey()!!
        data.key = key_mode
        refMode?.child("mode")?.setValue(data)
    } else {
        // 更新
        val map = mutableMapOf<String, Any>("mode" to mode)
        refMode?.child("mode")?.updateChildren(map)
    }
}
```

## モード切り替え(流れる端末)
- configの生成/変更を監視し、モードを再設定して画面を切り替えます。

```kotlin
// configのモード情報を生成
var refMode = database?.getReference("config")
refMode?.addChildEventListener(listenerChildEventMode)

// configのモード情報を監視
val listenerChildEventMode = object : ChildEventListener {
    override fun onCancelled(dataSnapshot: DatabaseError) { }

    override fun onChildMoved(dataSnapshot: DataSnapshot, previousChildName: String?) { }

    override fun onChildChanged(dataSnapshot: DataSnapshot, previousChildName: String?) {
        val value = dataSnapshot.getValue(ModeInfo::class.java)
        if (value != null) {
            var mode = ModeStage.values().filter { value.mode == it.mode }.first()
            // ステージ変更の初回のみ切り替え
            if (AppGlobal.modeStage != mode) {
                // ステージ生成
                createStage(mode)
                // ステージ初期化
                stageGame?.initialize(screen_width, screen_height)
                AppGlobal.modeStage = mode
            }
            // プレイ時間設定
            AppGlobal.timePlay = value.timePlay
        }
    }

    override fun onChildAdded(dataSnapshot: DataSnapshot, previousChildName: String?) {
        val value = dataSnapshot.getValue(ModeInfo::class.java)
        if (value != null) {
            var mode = ModeStage.values().filter { value.mode == it.mode }.first()
            // ステージ変更の初回のみ切り替え
            if (AppGlobal.modeStage != mode) {
                // ステージ生成
                createStage(mode)
                // ステージ初期化
                stageGame?.initialize(screen_width, screen_height)
                AppGlobal.modeStage = mode
            }
            // プレイ時間設定
            AppGlobal.timePlay = value.timePlay
        }
    }

    override fun onChildRemoved(dataSnapshot: DataSnapshot) { }
}
```

## 当たり判定
- 本当は2点タッチで掴む動作(タッチしたまま箸を動かして範囲が狭くなった)まで判定したかったのですが、現在はそうめん内で2点タッチしてれいばOKとしています。
今後は掴む動作に改良したいと思います。

# おわりに
- 展示会では、何で連携しているか(Bluetooth？、Wifi Direct？)問い合わせが多く、Firebase Realtime Databaseを使って、そうめんの位置をバケツリレーしてると答えると、驚きと納得があることが嬉しかったります。
- 流しそうめんは竹の節やつなぎ目でもたついたりするので、リアルタイム同期の速度やネットワークの遅延でもたつきが表現できたと思います。(前向きすぎるかも)
- 現在は流れる端末に事前に端末番号の登録が必要なので、新しく端末を追加したり、途中の端末を抜いたりの融通がききません。参加者の端末で、飛び入り参加などができると楽しいので改善したいと思います。
- 夢は100台(Firebaseの無料枠の最大接続数)の端末で流しそうめん！