LoginSignup
21
10

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-05-08

はじめに

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

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

間違いがあったら優しく指摘してください

なにつくろう・・・

テーマは「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のレスポンシブ化

地味に大変でした。お絵描きの時に座標を使うので要素のサイズ変更に合わせて適宜スクリプトでもサイズを変更する必要がありました。

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フォントを使用しました。
なんかかっこよかったからです
あとはsetIntervalを使って100msごとにカウントダウンさせるだけでした

Canvasの画像化&アップロード機能

これも重要な機能ですね!
まずCanvas上の画像をtoDataURLdata:image/jpeg;base64,/9j/4AAQSAH(すごーく長いので略)MAUAAAAf/2Q==のようなbase64でエンコードされたDataURLに変換します。
そのURLをこちらのCloud StorageのuploadStringくんにぶち込みます。
なんと勝手にjpgにしてアップロードくれるんですよね。ありがたや

uploadString(storageRef(storage,roomId.current + '.jpg', 
{ cacheControl: "no-cache" }), imageDataUrl, 'data_url').then(
       (snapshot) => {/*略*/}
);

{ cacheControl: "no-cache" }でキャッシュを無効化しようとしてます。これがないと同じファイル名なので前の画像ファイルをキャッシュから取得してしまいますね。(これが関係ないかもしれない話はこの後すぐ!)

画像ダウンロード機能

地味に大変でした
最初、私は以下のようなコードで画像をダウンロードさせていました。

 getBlob(storageReference).then((blob) => {
                    const url = window.URL || window.webkitURL
                    setImgUrl(url.createObjectURL(blob));
                })

Blobなのは速そう!と思ったからです。(実際速く感じました)
しかし、中身が違うファイル(名前は同じ)をダウンロードしても中身が変わっていませんでした。
そこで{ cacheControl: "no-cache" }を付けましたが改善されず・・・
調べるとcacheControlはHTTPヘッダーとのことなのでXMLHttpRequestなら行けるのでは?と思い、getDownloadURLで以下のように実装することができました。

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
  • スタート待ち
  • お絵描き
  • 話し合い&回答
  • 答え合わせ
  • 結果発表
const GameState = {
    //C#とかKotlinのenum風
    WAIT_MORE_MEMBER: 'waitMember', //独りぼっち さみしい

    WAIT_START: 'waitStart', //スタート待ち
    DRAW: 'draw', //お絵描き中、画像アップロード待ち
    CHAT: 'chat', //話し合い中
    CHECK_ANSWER: 'checkAnswer', //答え合わせ
    RESULT: 'result', //結果ー>戻る WAIT_START
};

アプリ側でこの状態を監視することで次にどんなデータを取得すればいいかを判別させています。

状態の監視

useEffect(() => {
    switch (getGameState()) {
        case GameState.WAIT_START:{
            Clean();//変数初期化
            setOdai(getRandomOdai());//お題をランダムに取得
        }
        //こんな感じのが続くので省略
    }
},[stateGameState]);//depsの意味がよくわかってません

データの更新

onSnapshot(roomDoc, {
    next: (doc) => {
        const data = doc.data();
        setroomName(data.Name);
        setGameState(data.State);
        setPainter(data.Painter);
    },
});

ここで自身のルームのデータ(rooms/{ルームID}以下)を監視&取得しています。

データの更新 Part.2

このリスナー君だけ働く量が異常です。
回答データの取得、メンバー名取得、メンバー人数に応じた状態変更までやらされています。
かわいそうですね

onSnapshot(q, {//"q"はrooms/{ルームID}/membersのコレクションへの参照
    next:(querySnapshot) = >{
        //いろいろかいてある
    }
}

メンバー名リストの取得

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が動くので

querySnapshot.docs.every(doc => doc.data().answer || getPainter() === doc.id)

上記の条件式で全員の回答がそろったことを検知しStateはCHECK_ANSWERに更新されます。
Painter側で正誤の判定をして送信するとStateがRESULTになり、全員の回答を見る時間が始まります。
Painter側が次のゲームへ進めるボタンを押すと、次のPainterをランダムで指定し、StateはWAIT_STARTに戻されます。
これの繰り返しでゲームを進めています。

最後に

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

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

  1. ブラウザ同時起動すれば・・・ぼっちでも遊べます!

21
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
10