TypeScript+React+Cloud Firestore+Stateモナドで、ブラウザで動くボードゲームの対戦ツールを作った (3/3 UI・通信編)

[前回](https://qiita.com/zonaryFUND/items/a3c3a02611529419aa93)はボードゲーム、[ナショナルエコノミー](https://bodoge.hoobby.net/games/national-economy)オンライン対戦ツールにおけるゲームメカニクス部のStateモナドを用いた実装手法について解説しました。
今回はCloud Firestoreとの通信、およびReactを用いたUIの実装を行います。

# はじめに

通信とUIの実装はメカニクス部と異なり実際に動かしながら動作を確認する必要があります。webpack-dev-serverを用いるなり、create-react-appを用いるなりしてローカルに開発環境を構築し、ブラウザからアクセスして見た目を確認しつつ行うのが手っ取り早いでしょう。
身内にのみ限定して共有する予定であったことから、本対戦ツールでは認証などの仕組みを実装しませんでした。したがって本記事で解説する実装は、あまり「安全」な構成にはなっていないことをご留意ください。

# Cloud Firestoreとの通信

## 接続

Firebase SDKの`initializeApp`関数を用いてCloud Firestoreとの接続を確立します。

```typescript

import firebase from "firebase/app";

const db = firebase.initializeApp({
    apiKey: "*******-*******************************",
    authDomain: "****.firebaseapp.com",
    databaseURL: "https://****.firebaseio.com",
    projectId: "****",
    storageBucket: "****.appspot.com",
    messagingSenderId: "************",
    appId: "*******"
  }).firestore();
```

スクリプトの冒頭で作成したこの`firebase.Firestore`型を全体で用いることで送受信を行います。

Cloud Firestoreのデータ構造は、最小単位を「ドキュメント」として、それを保有する「コレクション」によって成り立っています。ドキュメントはKVS型のレコードであり、実質的にはJSONと同じです。したがって、メンバすべてが数値、文字列、あるいはその配列やオブジェクトであるような`Game`型をそのままJSONとして送受信することができます。
実際には、ゲーム内各種操作のログも保持・共有できるように、合わせた型として次の`GameAndLog`を定義して、それを送受信します。

```typescript:game-and-log.ts
type GameAndLog = {
    game: Game,
    log: string[];
}
```

## 受信

ゲーム状態の取得はいちどきりではなく、書き込みが行われるたびにリアルタイムに更新される必要があります。これはコールバックの登録によっても購読できますが、今回は[rxfire](https://www.npmjs.com/package/rxfire)を用いて`Observable`の形でゲーム状態を購読することにします。

```typescript:fetch.ts
import * as firebase from "firebase";
import { Observable } from "rxjs";
import GameAndLog from "entity/gameandlog";
import { docData } from "rxfire/firestore";

export function fetchGame(db: firebase.firestore.Firestore, id: string): Observable<GameAndLog> {
    return docData(db.collection("games").doc(id));
}
```

事前準備としてCloud Firestoreのコンソール上で、「games」コレクションを作成しておきました。「games」コレクション内の適当な「ゲームID」ドキュメントを`GameAndLog`型として購読します。
あとはこれを必要な個所で`subscribe`すれば、リアルタイムに更新される`GameAndLog`が得られます。

## 送信

ゲーム状態の送信は、適切な操作がなされた場合にそれに対応するStateモナドと旧`GameAndLog`から新たな`GameAndLog`が作成された際に行われます。

```typescript:fetch.ts(続き)
export function updateGame(db: firebase.firestore.Firestore, id: string, game: GameAndLog) {
    db.collection("games").doc(id).update(game);
}
```

## 新たなゲームの開始

送受信メソッドに含まれる「ゲームID」は、Cloud Firestoreに対し新たなドキュメントを作成させる形で生成することができます。適当な手段で決定したリーダークライアントがこのメソッドを呼ぶことにより新規ゲームを開始します。

```typescript
function createGame(db: firebase.firestore.Firestore, id: string, game: Game) {
    db.collection("games").add({game: game, log: []})
        .then(doc => db.collection("rooms").doc(`room${id}`).update({game_id: doc.id}));
}
```

ゲーム開始前のロビーの状態を保持するコレクションとして「rooms」コレクションを定義しており、これに`add`メソッドで生成されたゲームIDを書き込むことで「部屋が進行中のゲームで埋まっている」ことを表現しています。

# UI

特別変わったことをするわけではなく、`Game`型で定義される現在のゲーム状態を用いて各種コンポーネントを順に作成していきます。

## ゲーム状態の購読

ゲーム状態が`Observable`で得られるため、次のような購読用カスタムフックを作成するとよいでしょう。

```typescript:use-observable.ts
import * as React from "react";
import { Subscription, Observable } from "rxjs";

export default function useObservable<T>(observable: Observable<T>): [T | null, any | null, () => void] {
    const [subscription, setSubscription] = React.useState<Subscription | null>(null);
    const [value, setValue] = React.useState<T | null>(null);
    const [error, setError] = React.useState<any | null>(null);

    const ref = React.useRef(subscription);

    React.useEffect(() => {
        setSubscription(observable.subscribe(v => {
            setValue(v)
        }, setError));
        return () => ref.current?.unsubscribe();
    }, []);

    return [value, error, () => subscription?.unsubscribe()];
}
```

## Reactコンポーネントの作成

ナショナルエコノミーはすべてのコンポーネントがカードで構成されたゲームであり、「手札」や「所有不動産」など、カードを並べる似た形式のコンポーネントを多く用いることになります。
コンポーネントの作成手段についてはいくらでも記述が見つかると思われるので、ここでは一例として`cards`コンポーネントだけを取り上げます。

```react:cards.tsx
import * as React from "react";
import Card from "./atoms/card";
import * as style from "./cards.styl";
import { CardName } from "model/protocol/game/card";
import { Building } from "model/protocol/game/building";
import Workers from "./atoms/workers";

type Props = {
    title: string,
    tooltip?: string,
    cards?: CardName[],
    buildings?: Building[]
};

const hand: React.FC<Props> = props => {
    const cards = props.cards ? props.cards.map((c, i) => <Card card={c} key={`${i}-${c}`} />) : null;
    const buildings = props.buildings ? props.buildings.map((b, i) => (
        <Card card={b.card} key={`${i}-${b.card}`}>
            <Workers owners={b.workersOwner} />
        </Card>
    )) : null;

    return (
        <section className={style.cards}>
            <h2 title={props.tooltip}>{props.title}</h2>
            {cards ? <ul>{cards}</ul> : null}
            {buildings ? <ul>{buildings}</ul> : buildings}
        </section>
    );
};

export default hand;
```

# まとめ

今回の記事でCloud Firestoreとの通信、およびUIの構成について説明したため、これでゲーム作成に必要な要素を構築できるようになりました。
Firebase Authenticationを利用して、ゲームプレイにユーザ登録を要求するなど、触れていない部分も多くありますが、とりあえずStateモナドとCloud Firestoreの組み合わせ方については解説できたのではないかと思われます。

# 参考文献

[Cloud Firestore](https://firebase.google.com/docs/firestore?hl=ja)