今年の正月はNuxtとFirebaseで作ったカードゲームで甥っ子に尊敬されたい

## この記事は
[FORK Advent Calendar 2019](https://qiita.com/advent-calendar/2019/fork)　18日目の記事です。

- NuxtでのPWA対応Webアプリ開発
- Cloud Firestore（以下Firestore）を使ったリアルタイムなユーザー間通信

の実践入門として、簡単なカードゲームを作ってみたお話です。

### 背景

実家に帰省するたびに、愛すべき甥っ子（小3）におもちゃやらゲームやらを買い与えては一緒に遊んでいるので
今のところは**一緒に遊んでくれる楽しいおじさん**として無邪気になついてくれているのですが、
小学校中学年ともなるとそろそろ**半年に一回帰ってくる金ヅルおじさん**[^1]になりますよね。

そんな無慈悲な烙印を押される前に、ここらでいっちょ**ゲームを作れる金ヅルおじさん**になってやろうじゃありませんか。

### おことわり（言い訳）
Firebaseの実践入門ということでなにかと勢いまかせのｴｲﾔｯで作ってしまったため、
未完成のアプリケーションなうえ、[Sparkプラン](https://firebase.google.com/pricing?hl=ja)です。
そのため現時点ではURLの公開等はせずに開発途中の画面、コードでお届けします。

> 「逆に考えるんだ。“未完成のものでも「NuxtやFirebaseで何か作りたい」という人へのきっかけになれればいいさ”と考えるんだ」
ジョースター卿

## 構成
- Nuxt 2.9.2
  - 静的ジェネレートSPA（PWA対応）
  - Typescript
  - Vuetify（UIコンポーネント）
- Firebase
  - Firebase Hosting（ホスティング）
  - Cloud Firestore（データベース）

## 作るもの（ゲームの内容）
「ザ・マインド」というカードゲームのルールを基に自作します。

全員で1～100の数字を小さい順に出す、というプレイヤー協力型ゲームです。
カードゲームと言ってもルールがシンプルで自作できそうだったのでチャレンジしてみました。
（某こどものおつかいやあらへん番組でも紹介されていました）

詳しいルールについては下記を参考に。
[The Mind-ザ・マインド｜相談ができない孤独の協力ゲーム | 遊びゴコロ](https://asobi556.com/intro/themind/)

### ルール概要
- :family_mmgb: プレイ人数：2人以上
- :baby: 対象年齢：1から100まで数を数えられればたぶんOK
- :black_joker: プレイヤーには1～100の数字が書かれたカードの中からランダムで1枚配られます
- :zipper_mouth: **プレイヤー同士の会話やジェスチャーは一切禁止です**[^2]
- :o: 数字の少ない順に全員が手札を場に出せたらクリアです（次のレベルに進む）
- :x: 場に出したカードより少ない数字を誰かがまだ持っていたら失敗です
- :arrow_heading_up: 手札の枚数はレベルが上がるたびに1枚ずつ増えます（レベル2では1人2枚）

要は会話やジェスチャーをせずに、顔色や間を窺って数字を順番に出していく、
信頼関係が試されるゲームです。
はたして甥っ子と金銭授受以外の手段で信頼関係を築けるのか・・！

### 実装していない本家ルール
- ライフポイント（失敗したら-1、0になったら終了）
- 流星カード（各プレイヤーの一番小さな数字のカードを開示して取り除けるアイテム）
- レベルボーナス（特定のレベルクリアでのライフの回復、アイテムの付与など）

## できたもの

前置きが長くなりましたね、こちらができたものです

### プレイヤー登録画面
![01.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315402/ef5ce72c-2c27-353f-22ab-c55760dec293.gif)


（部屋とかルームとか表記が定まってない感:worried:）

1. ホストとなる人が**部屋を作る（ホスト）**から、参加するプレイヤーの名前を登録して部屋を作ります。  
このときに部屋番号となるチャンネルIDが登録されます。
2. ゲストとなる人が、ホストの画面のQRコードを読むか、**部屋に入る（ゲスト）**からチャンネルIDを入力します。
3. プレイヤー選択画面で全員が選択したらゲームスタートです。

### ゲーム画面（成功 / 失敗パターン）
![02.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315402/f35531fe-1654-93af-a50e-2aded866fc27.gif)

1. ゲーム開始時に手札が配布されます。
2. カードを出すときは「提示するカードを表示」を押すと、自分の他の手札が見えなくなります。
3. 2の画面を他のプレイヤーに見せながら、判定ボタンを押すと、その数字が通ったかどうかが判定されます。  
  （ほかに小さい数字があれば失敗）
4. これを全員がカードを出すまで繰り返します。
5. 成功の場合は次のステージに進むか終了かを選べます
6. 失敗の場合はリトライするか終了かを選べます

## 環境構築と参考記事

### Nuxt + Typescript
以前こちらの記事にまとめました。
[Nuxt v2.9 + Typescriptの環境構築](https://qiita.com/yoh_zzzz/items/9e2611b7a994427474f6)

### Firebase
こちらを参考にさせていただきました。ありがたや～～:bow_tone1:

- [Nuxt v2とFirebase(CloudFirestore)でPWA対応Webアプリ開発](https://qiita.com/_takeshi_24/items/3ee051e1db1b3e8da106)
- [【v2対応】Nuxt.jsとFirebaseを組み合わせて爆速でWebアプリケーションを構築する](https://qiita.com/potato4d/items/cfddeb8732fec63cb29c)
- [『Nuxt + Firebase Hosting』で超速deploy](https://qiita.com/omochironn/items/27d80d5ef470cdb2baca)

### Vuetify
UI FrameWorkに[Vuetify](https://vuetifyjs.com/ja/)を採用しています。以下が参考になりました:bow_tone1:

- [Vuetify2をNuxt.jsで試してみた](https://freegian.co.jp/blog/vuetify2-nuxt-js)

## データの設計
ゲームに必要なデータは以下の通り設計しました。
カッコ内はVuexのストアモジュールを指します。そのままですね。

- チャンネルID（`store/index.ts`）
  - プレイヤーをマッチングさせるための部屋番号
- プレイヤー情報（`store/player.ts`）
  - ID
  - 名前
  - ホストorゲスト
  - プレイヤー選択画面での選択状態
- ゲーム情報（`store/game.ts`）
  - 全カードの配列
  - 各プレイヤーの手札
  - 現在のステージ数
  - 現在出されているカードの枚数
  - 最後に出されたカードの番号
  - 次の正解となるカードの番号
  - 成功 / 失敗 / ゲームをやめた　などの状態
 
これらをVuexとFirestoreで同期させます！
 
### Vuex
こんな感じ

![image.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315402/d550c645-4a35-d219-70c5-6fee843e6d6a.gif)


### Firestore

Firestoreは、
`mindコレクション > チャンネルIDのドキュメント > gameとplayerのフィールド`
という構造でVuexと同期します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315402/45d9e875-6337-8712-8e92-8f8097e16027.png)

## 実装

### VuexとFirestoreの同期 / 更新

Firestoreが提供する`onSnapshot()`メソッドと`set()`メソッドを使ってデータの同期と更新を実装します。

- 同期処理 [onSnapshot()メソッド](https://firebase.google.com/docs/firestore/query-data/listen?hl=ja)
- 更新処理 [set()メソッド](https://firebase.google.com/docs/firestore/manage-data/add-data?hl=ja)

これらの処理はすべてVuexの`actions`に持たせることで、
コンポーネント（vueファイル）側ではFirestoreを意識しなくていいようにしました。

#### 同期

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315402/8173dff6-3f30-3510-7f59-3162f34f7a52.png)

##### ゲーム情報の同期部分（Vuexストアモジュール）

```store/game.ts
import firebase from '@/plugins/firebase'
const db = firebase.firestore()
// ===== 略 =====
export const state = (): GameState => ({
  cardList: [],
  count: 0,
  hand: {},
  pushHand: null,
  next: null,
  stage: null,
  success: false,
  failed: false,
  exit: false
})
// ===== 略 =====
export const mutations = {
  gameDataSync(
    state: GameState,
    payload: GameState
  ) {
    if (payload) {
      Object.assign(state, payload)
    }
  },
// ===== 略 =====
export const actions = {
  gameDataFetch({ commit, rootState }) {
    /* Firebaseの連携 */
    const channelId: string | null = rootState.channel
    if(channelId !== null) {
      //- コレクションとドキュメント識別子（チャンネルID）を明示的に指定する
      db.collection('mind')
        .doc(channelId)
        //- onSnapshotで対象のドキュメントをリッスン
        .onSnapshot((doc) => {
          //- コールバックでデータ更新時の処理
          const docData = doc.data()
          if (docData) {
            commit('gameDataSync', docData.game)
          } else {
            console.error(`エラー：ゲームデータが取得できませんでした`)
          }
        }, (error) => {
          console.error(`エラー：${error}`)
        })
    }
  },
  // ===== 略 =====
```

ゲーム開始などのよきタイミングで`gameDataFetch`を一度`dispatch`することで
Firestoreの対象のドキュメントに変更があった場合に`game.ts`の`state`も更新されるようになります。
あとはコンポーネント側で`computed`なり`watch`なりで変更を感知させます。

#### 更新

データ更新のタイミングは
1. ステージ開始時（カードをシャッフルして配布）
2. プレイヤーがカードを出した時
3. 次のステージ/リトライ/終了などのステージ更新時
です。

2を例にすると、Vuexに成否判定後にFirestoreへのマージ処理を担う`action`を用意して、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315402/1ce188a7-327b-60e3-7ae1-1347187c0692.png)のclickイベントで`dispatch`する設計にしました。

##### action

```store/game.ts
// ===== 略 =====
export const actions = {
  // ===== 略 =====

  /* 手札の初期化action */
  gameInit({ state, rootState, rootGetters }) {
    const player: Array<Player> = rootGetters['player/getPlayerList']
    const playerIds: Array<string> = []
    const playerLen: number = player.length
    const handLen: number = state.stage !== null ? state.stage : 1
    const hand: {} = {}
    const cardsLen: number = handLen * playerLen
    const handred: Array<number> = []

    player.map((e: Player) => {
      playerIds.push(e.id)
    })
    // 1 - 100 の配列を用意
    for (let i: number = 0; i < 100; i++) {
      handred.push(i + 1)
    }
    // Fisher-Yates アルゴリズムによるシャッフル
    for (let i: number = handred.length - 1; i > 0; i--) {
      const j: number = Math.floor(Math.random() * (i + 1))
      const tmp: number = handred[i]
      handred[i] = handred[j]
      handred[j] = tmp
    }
    const cards: Array<number> = handred.splice(0, cardsLen)
    const cardList: Array<number> = [...cards].sort((a, b) => {
      return a - b
    })
    // cardの配布
    for (let i = 0; i < playerLen; i++) {
      const id: string = playerIds[i]
      const handCards: Array<number> = cards.splice(0, handLen)
      const sortedhandCards: Array<number> = handCards.sort((a, b) => {
        return a - b
      })
      hand[id] = sortedhandCards
    }
    const channelId: string = rootState.channel
    const initializeData: {
      game: GameState
    } = {
      game: {
        stage: handLen,
        hand,
        pushHand: null,
        cardList,
        count: 0,
        next: cardList[0],
        success: false,
        failed: false,
        exit: false
      }
    }
    //- FirestoreにinitializeDataオブジェクトをマージ
    db.collection('mind')
      .doc(channelId)
      .set(initializeData, { merge: true })
  },

  /* 手札の開示action */
  pushHand({ state, rootState }, payload: number) {
    const channelId: string = rootState.channel
    const pushData: GameData = {
      game: {
        pushHand: payload
      }
    }

    /* 中略
     * 数字を引数payloadで受け取って、
     * 成功や失敗の判定、成功の場合は次の正解の番号を取得などしてから、
     * pushDataオブジェクトにデータを追加。
     */

    //- FirestoreにpushDataオブジェクトをマージ
    db.collection('mind')
      .doc(channelId)
      .set(pushData, { merge: true })
  },
// ===== 略 =====
```

図にするとこんな感じになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/315402/1c66f551-067c-e06e-5330-331ba3d0fd00.png)

## 感想

今回はFirebase（Firestore）にとにかく触ってみる、
という目的でカードゲームを作ってみました。

正直自分はDBの知識はさっぱりなのですが、
FirestoreのドキュメントはJSONレコードとして扱え[^3]、連携処理もFirebaseのAPIのおかげでとても簡単でした。

簡単すぎるがゆえに、自分の無学に不安を覚える箇所も多々あるので、
これを機にFirebaseやDBについてきちんと学んでいきたいと思います。

ボードゲームやカードゲームは、単純なルールでも奥が深いものが多く、
開発のモチベーションも保ちやすいためチャレンジ課題としておすすめです:beginner:
（サービスとして公開する場合は著作権にも注意しましょう）

ゲームを作れる金ヅルおじさんはお年玉もしっかりあげます。（金ヅルなので[^1]）

-----

[:christmas_tree: FORK Advent Calendar 2019](/advent-calendar/2019/fork)
:arrow_left: 17日目 [Docker入門 ～Dockerfile編～](/takaday/items/08c8269dc926af8cb661) @takaday
:arrow_right: 19日目 [Advent Calendar だから Illustrator + JavaScript でお菓子を作ってみる](https://qiita.com/momo_r/items/5e86eb91adb4a73e7c95) @momo_r


[^1]: 彼はとても素直で家族想いのいい子なので、そんなことは思いません、おじさん信じてるよ
[^2]: ゲームの肝となるルールですが、数字を直接的には言わなければ会話もOK、とかにしても楽しいらしいです（やったことないけど）
[^3]: 出典：[Cloud Firestore データモデル](https://firebase.google.com/docs/firestore/data-model?hl=ja)