【高専ベンチャー社×ゆめみ社 2022ハッカソン春】ReactとFirebaseでお絵描き×チャットアプリを作る

# はじめに
こんにちは、高専ベンチャー社×ゆめみ社 ハッカソン チームDのnaotikiです。
この度ハッカソンに初参加してアプリを作って優勝したので主に私が実装した部分の
技術的な事を書いていきたいと思います。

↓ @tos-up先輩のこちらの記事も是非読んでください！   ↓

https://qiita.com/tos-up/items/0b783c87dadb905b01c1

間違いがあったら優しく指摘してください
# なにつくろう・・・
テーマは「WITHコロナ時代のコミュニケーションを活性化せよ」
とあるチームの会話(一部省略)
Aさん「何作ろう」
Bさん「みんなで同じ課題に取り組むのがいいですよね！」
Aさん「お絵描きっていいですよね！」
Cさん「作ろう！」

こうしてお絵描きチャットアプリが作られることになりました。
# 開発開始！
こうして開発が始まりました
フロントエンドのフレームワークはチームメンバーが触ったことあったり、興味があったりしたReactに決定しました。
~~本当はKotlin/JSでもよかったKotlin最高(しかしwrapper書くのめんどくさい)~~
データベースなどはFirebaseだけでいけそうなのでFirebaseになりました
私の開発環境
> - OS : Windows 11 Home
> - IDE : InteliJ IDEA Ultimate

使用したパッケージたち

>React 18.1.0
>MUI 5.6.3
>react-cookie 4.1.1
>Firebase SDK 9.7.0


# お絵描きできるようになる

お絵描き機能実装にあたって大変だった箇所を書いていきます。
### お絵描き機能実装にあたってCanvasに必要な機能たち
- Canvasのレスポンシブ化
- 時間制限機能
- Canvasの画像化&アップロード機能
 
## Canvasのレスポンシブ化
地味に大変でした。お絵描きの時に座標を使うので要素のサイズ変更に合わせて適宜スクリプトでもサイズを変更する必要がありました。
```js
useEffect(() => {
        const observer = new ResizeObserver((entries) => {
            canvasRef.current.width = entries[0].contentRect.width;
            canvasRef.current.height = aspectRatio * entries[0].contentRect.width;
            if (canvasContext.current != null) {
                clear();
                canvasContext.current.lineWidth = props.penRadius;
            }
        });
        if (canvasRef.current) {
            // 要素を監視
            canvasRef.current && observer.observe(canvasRef.current);
        }
        // クリーンアップ関数で監視を解く
        return () => {
            observer.disconnect();
        };
    }, []);
```
ResizeObserverでCanvasのサイズ変更の監視をしています。ペンの解像度も変わってしまうので`clear()`できれいにしています。

## 時間制限機能
このアプリで最も重要な(?)機能ですね。
タイマー表示用のフォントとして[DSEG](https://github.com/keshikan/DSEG)フォントを使用しました。
~~なんかかっこよかったからです~~
あとは`setInterval`を使って100msごとにカウントダウンさせるだけでした

## Canvasの画像化&アップロード機能
これも重要な機能ですね!
まずCanvas上の画像を`toDataURL`で`data:image/jpeg;base64,/9j/4AAQSAH(すごーく長いので略)MAUAAAAf/2Q==`のようなbase64でエンコードされたDataURLに変換します。
そのURLをこちらのCloud Storageの`uploadString`くんにぶち込みます。
なんと勝手にjpgにしてアップロードくれるんですよね。ありがたや
```js
uploadString(storageRef(storage,roomId.current + '.jpg', 
{ cacheControl: "no-cache" }), imageDataUrl, 'data_url').then(
       (snapshot) => {/*略*/}
);
```
`{ cacheControl: "no-cache" }`でキャッシュを無効化しようとしてます。これがないと同じファイル名なので前の画像ファイルをキャッシュから取得してしまいますね。(これが関係ないかもしれない話はこの後すぐ！)
## 画像ダウンロード機能
地味に大変でした
最初、私は以下のようなコードで画像をダウンロードさせていました。
```js
 getBlob(storageReference).then((blob) => {
                    const url = window.URL || window.webkitURL
                    setImgUrl(url.createObjectURL(blob));
                })
```
Blobなのは速そう！と思ったからです。(実際速く感じました)
しかし、中身が違うファイル(名前は同じ)をダウンロードしても中身が変わっていませんでした。
そこで`{ cacheControl: "no-cache" }`を付けましたが改善されず・・・
調べると`cacheControl`はHTTPヘッダーとのことなので`XMLHttpRequest`なら行けるのでは？と思い、`getDownloadURL`で以下のように実装することができました。
```js
getDownloadURL(storageRef(
    storage,
    roomId.current + '.jpg'
    ))
.then((url) => {
    const xhr = new XMLHttpRequest();
    xhr.responseType = 'blob';
    xhr.onload = (event) => {
        const blob = xhr.response;
    };
    xhr.open('GET', url);
    xhr.send();
    setImgUrl(url);
})
```
これが正解なのかがわかりません・・・

# データベースの構造
## Firestore
ルームの情報などが保存されています。
```
rooms:
    {ルームID}:
        Name:{ルームの名前}
        Painter:{絵を描く人のユーザーID}
        State:{ゲームの進行状況}
        members:
            {ユーザーID}:
                name:{ユーザー表示名}
                answer:{回答}
                isCorrect:{回答があっているか}
                
```
ルームIDやユーザーIDは`addDoc`でFirestore側から自動生成されるIDを用いています。

## Realtime Database
チャットのデータたちが保存されています。
```
rooms:
    {ルームID}:
        {自動生成されるID}:
            msg:{メッセージ}
            timeStamp:{送信日時}
            userId:{発言者のユーザーID}
```
# 進行状況の管理
このアプリでは以下のような状態をFirestore内の`State`というフィールドで管理しています。
- 参加待ち(ぼっち)(一人では遊べないので) [^1]
- スタート待ち
- お絵描き
- 話し合い＆回答
- 答え合わせ
- 結果発表
```js
const GameState = {
    //C#とかKotlinのenum風
    WAIT_MORE_MEMBER: 'waitMember', //独りぼっち　さみしい

    WAIT_START: 'waitStart', //スタート待ち
    DRAW: 'draw', //お絵描き中、画像アップロード待ち
    CHAT: 'chat', //話し合い中
    CHECK_ANSWER: 'checkAnswer', //答え合わせ
    RESULT: 'result', //結果ー＞戻る WAIT_START
};
```
アプリ側でこの状態を監視することで次にどんなデータを取得すればいいかを判別させています。
#### 状態の監視
```js
useEffect(() => {
    switch (getGameState()) {
        case GameState.WAIT_START:{
            Clean();//変数初期化
            setOdai(getRandomOdai());//お題をランダムに取得
        }
        //こんな感じのが続くので省略
    }
},[stateGameState]);//depsの意味がよくわかってません
```
#### データの更新
```js
onSnapshot(roomDoc, {
    next: (doc) => {
        const data = doc.data();
        setroomName(data.Name);
        setGameState(data.State);
        setPainter(data.Painter);
    },
});
```
ここで自身のルームのデータ(`rooms/{ルームID}`以下)を監視＆取得しています。
#### データの更新 Part.2
このリスナー君だけ働く量が異常です。
回答データの取得、メンバー名取得、メンバー人数に応じた状態変更までやらされています。
かわいそうですね
```js
onSnapshot(q, {//"q"はrooms/{ルームID}/membersのコレクションへの参照
    next:(querySnapshot) = >{
        //いろいろかいてある
    }
}
```
メンバー名リストの取得
```js
querySnapshot.forEach((doc) => {
    tmp[doc.id] = doc.data().name;
});
```
参加人数が1人ならStateは`WAIT_MORE_MEMBER`になります。
二人以上なら`WAIT_START`にしてくれます。
WAIT_STARTになるとPainterはスタートボタンを押せるようになり、Stateは`DRAW`になります。
描き終わる(3秒の時間制限が終わる)と自動でStateは`CHAT`になり回答が送れるようになります。

回答が送信された時もonSnapshotが動くので
```js
querySnapshot.docs.every(doc => doc.data().answer || getPainter() === doc.id)
```
上記の条件式で全員の回答がそろったことを検知しStateは`CHECK_ANSWER`に更新されます。
Painter側で正誤の判定をして送信するとStateが`RESULT`になり、全員の回答を見る時間が始まります。
Painter側が次のゲームへ進めるボタンを押すと、次のPainterをランダムで指定し、Stateは`WAIT_START`に戻されます。
これの繰り返しでゲームを進めています。

# 最後に
初めて参加したハッカソンでしたが、貴重な共同開発の経験をすることができたり、ReactやJavaScriptについて少し知ることができたりと、とても勉強になりました。
とっても楽しかったのでまた参加したいです！
先輩のおかげでGitの使い方もわかりました！

チームの方々、他の参加者の皆様、スタッフの皆様、お疲れさまでした。

https://draw-rush.web.app/

https://github.com/Yumemi-Hackathon22-D/DRAW-RUSH

[^1]:ブラウザ同時起動すれば・・・ぼっちでも遊べます！