🔥Firebase🔥でゲーム内に簡易SNSを作る

f:id:uracon:20180407185356p:plain:w750

カジュアルゲームを作っているときの「簡単な交流要素を入れたいけど、サーバーを運用するのはちょっと……」という要件にはmBaaSが便利です。この記事では先月リリースした「勇者、27歳、独身」の事例を元に、FirebaseでUnity製ゲームに簡易SNSを実現する方法を紹介します。

ゲームは以下のリンクからダウンロードできます。 tavern-ad9b4.firebaseapp.com

相談機能の紹介

勇者、27歳、独身は酒場にいる勇者の悩みを聞くゲームですが、カウンターの上にいる黒猫に話しかけるとプレイヤ自信の悩みを聞いてくれます。

f:id:uracon:20180407133024p:plain:w384

また、隣にいる白猫は他のプレイヤが投稿した悩みを持ってきてくれます。

f:id:uracon:20180407133100p:plain:w384

匿名でメッセージを投稿すると誰かしらが返信してくれる、いわゆるボトルメール系のサービスです。今晩の献立から人間関係まで、様々な悩みが投稿されています。

f:id:uracon:20180407185933p:plain:w320

類似サービスをいくつか参考にしたのですが、なるべく「気軽な気持ちで簡単に投稿できる」サービスにしようと思い、特に以下のような点を意識しました。

  1. 会話が成立しない
    • オンライン上で匿名の議論をすると大抵の場合荒れるので、そもそも言い合いが起こり得ないよう「悩みを相談して誰かが回答する」以降のやりとりをできなくしました。
  2. 見返りが無い
    • 例えば「悩みに答えるとゲーム内のコインが貰える」といったこともできるのですが、あくまでコミュニケーション自体を楽しんでもらいため、そういった見返りは無くしました。
  3. 履歴が残らない
    • 昨今の「twitterで過去の発言が引用されて炎上」や「Snapchatを始めとしたすぐ消える系のサービスが人気」といったニュースから、気軽なコミュニケーションにおいて過去の投稿が全て残る意味はさほど無いのではないかと仮定し、同時に投稿できる悩みを1つまでにしました。

プレイヤの方からよく「履歴を見たい」「返信したい」といった要望をいただくのですが、とにかく気軽さを重要視しており、敵意や悪意を持って投稿するモチベーションをなるべく排除したいという意図があるので、これらの要望には応えられません。

DBの選択

では実装を紹介します。まずDBですが、FirebaseにはRealtime Database(以下rtdb)とFirestoreという選択肢があります。基本的にはFirestoreの方が新しいDBで、rtdbでは書けないような複雑なクエリを書くことができます。公式でも「β版であることを許容できるならFirestoreを使うべき」と書かれています。

Choose a Database: Cloud Firestore or Realtime Database  |  Firebase

ですが、今回はUnityのSDKが提供されているrtdbを採用しました。FirestoreのUnitySDKは公式に提供されておらず(2018/04/05現在)自分で書くのは大変なのと、今回の要件ならrtdbでも充分こと足りるだろうという意図です。

データ構造の定義

次にデータ構造を定義します。rtdbはRDBのようにテーブルやレコードといった概念は無く、1つの大きなJSONツリーでDB全体を表現します。今回は相談を表現する/postというトップレベルのノードを作り、/post/{userID}以下にユーザーごとの相談を作りました。

{
  "post": {
    "taro": {
      "question": "転職しようかな……",
      "answers": {
        "one": "もうちょっと頑張ろう",
        "two": "公務員がいいよ"
      }
    }
    "jiro": { ... },
    "hanako": { ... }
  }
}

上記の例ではtaroやhanakoといった文字列で表していますが、これはFirebaseAuth等で生成したユーザーごとにユニークな文字列を想定しています。

各/post/{userID}以下には相談内容を表すquestion、回答を表すanswersがあります。1つの相談に複数の回答が付く場合があるためanswersは更に子ノードを持っています。

ここに相談を書き込んでいきます。コードはこんな感じです。

class Post
{
    public string question = "";
    public Dictionary<string, Answer> answers;
}

class Answer
{
    public string body = "";
}

void sendPost(string text)
{
    var rootRef = FirebaseDatabase.DefaultInstance.RootReference;
    var postRef = rootRef.Child("post").Child(FirebaseAuth.DefaultInstance.CurrentUser.UserId);
    var post = new Post();
    post.question = text;
    postRef.SetRawJsonValueAsync(JsonUtility.ToJson(post)).ContinueWith(_ =>
    {
        // done
    });
}

void sendAnswer(string text, string target)
{
    var rootRef = FirebaseDatabase.DefaultInstance.RootReference;
    var answerRef = rootRef.Child("post").Child(target).Child("answers").Push();
    var answer = new Answer();
    answer.body = text;
    answerRef.SetRawJsonValueAsync(JsonUtility.ToJson(answer)).ContinueWith(_ =>
    {
        // done
    });
}

今回は「ユーザーが同時に投稿できる相談は1個」という制約があるため、新しく相談を投稿するときはsetで上書きしています。もしユーザーごとに複数の相談を投稿できるようにする場合は、さらに/post/{userID}/{postID}のように一段深くネストさせてpushしていく感じになると思います。

なるべく回答がついてない相談を探す

相談に回答するときは、postの中から「まだあまり回答が集まっておらず、なるべく古い投稿」を探してきます。こうすることで、どんな相談を投稿しても誰かしらからは返事が来る状況を実現しています。これは、全てのpostを①回答数昇順②投稿時間昇順でソートしてその先頭を表示させます。

f:id:uracon:20180407180432p:plain

これを実現するため、ソート用のデータ構造を定義します

{
  "postQueue": {
    "taro": {
      "mtime": 1000,
      "answer": 1,
      "sortKey": 11000
    },
    "jiro": { ... },
    "hanako": { ... }
  }
}

トップレベルに/postQueueを作ります。この下にuserIDごとに更新時刻、回答数、ソート用のフィールドを定義します(rtdbでは複数のフィールドでソートすることができないためソート用のフィールドが別途必要で、これは単に回答数と更新時刻をつなぎ合わせた値です)。

検索する処理は以下のような感じです。

void fetchPostQueue()
{
    var postQueueRef = FirebaseDatabase.DefaultInstance.RootReference.Child("postQueue");
    postQueueRef.OrderByChild("sortKey").LimitToFirst(10).GetValueAsync().ContinueWith(task =>
    {
        foreach (var postQueue in task.Result.Children)
        {
            // do something
        }
    });
}

ちなみに、これらソート用のフィールドは/post/{userID}に以下に直接定義してもいいのですが、やっぱり複数相談を投稿したくなって/post/{userID}/{postID}となった場合などはこのようにソート用のノードが必要です。また、rtdbでは特定のノード以下を一括して持ってくることしかできないため、post1つのデータ量が大きかった場合余計な転送が増えてしまうという点もあります。今回はどちらも問題では無いのですが、検索用のノードを別途作るというのはrtdbではよく使う手法です。

indexも忘れずに定義しておきましょう。

{
  "rules": {
    "postQueue": {
      ".indexOn": [
        "sortKey"
      ]
    }
  }
}

Cloud Functionsで処理を挟む

さて後は回答に合わせてこの/postQueueを更新すればokです。今回のアプリでは回答待ちのpostのソート順は全ユーザー通して一定なので、複数のユーザーが同時に同じ質問に答える可能性があります。回答数の値が不整合を起こさないようにするためトランザクションの処理を入れてもいいのですが、せっかくなのでCloud Functionsを使ってサーバー側で処理してみましょう。

Cloud FunctionsはFirebase製品の1つで、サーバー側の処理を書けます。端末ごとに共通のロジックや、セキュリティ上の都合でクライアントに置きたくない処理を書くなどの用途に便利です。HTTPSでリクエストを投げて直接実行したり、ユーザーが登録したタイミングで実行したりできるのですが、今回はrtdbの書き込みを監視して実行するようにします。

exports.noticeAnswer = functions.database.ref('/post/{uid}').onUpdate((data, context) => {
    const prevAnswers = data.before.child("answers").val()
    const newAnswers = data.after.child("answers").val()
    const prevNum = prevAnswers ? Object.keys(prevAnswers).length : 0
    const newNum = newAnswers ? Object.keys(newAnswers).length : 0
    if (newNum > prevNum) {
        const now = Math.floor(new Date().getTime() / 1000)
        return admin.database().ref('/postQueue/' + context.params.uid).update({
            answer: newNum,
            mtime: now,
            sortKey: newNum > 0 ? Number(newNum + '' + now) : now
        })
    }
    return null
})

onUpdateは指定したパス以下を監視し、更新があれば実行されます。data.beforeから変更前のデータにアクセスできるのでこれを更新後のデータと比較し、差分があれば/postQueue/{userID}を更新します。

push通知を送る

最後に、回答が投稿されたらpush通知を送るようにします。これもFirebase製品のMessagingを使えば今までのコードと組み合わせて簡単に実現できます。証明書などの設定は済んでいるものとして、まずクライアント側はこのようになります

FirebaseMessaging.TokenReceived += (_, args) =>
{
    var token = args.Token;
    postRef.UpdateChildrenAsync(new Dictionary<string, object>() { { "postBy", token } });
};

FCM tokenを取得してpostに付与しています。これで、/postのデータ構造は次のようになります。

{
  "post": {
    "taro": {
      "question": "転職しようかな……",
      "postBy": "{fcmToken}",
      "answers": {
        "one": "もうちょっと頑張ろう",
        "two": "公務員がいいよ"
      }
    }
    "jiro": { ... },
    "hanako": { ... }
  }
}

このtoken宛に通知を送れば、相談を投稿したユーザーに回答が届きます。ちょうど先程Functionsで回答が投稿されたときの処理を書いたので、push通知を送るコードも書き加えましょう。

exports.noticeAnswer = functions.database.ref('/post/{uid}').onUpdate((data, context) => {
    const prevAnswers = data.before.child("answers").val()
    const newAnswers = data.after.child("answers").val()
    const prevNum = prevAnswers ? Object.keys(prevAnswers).length : 0
    const newNum = newAnswers ? Object.keys(newAnswers).length : 0
    if (newNum > prevNum) {
        const news = Object.keys(newAnswers).filter(e => !prevAnswers || !prevAnswers[e])
        const now = Math.floor(new Date().getTime() / 1000)
        return Promise.all(news.map(e => admin.messaging().sendToDevice(data.after.val().postBy, {
            notification: {
                title: '回答が届きました',
                body: newAnswers[e].body
            }
        })).concat(admin.database().ref('/postQueue/' + context.params.uid).update({
            answer: newNum,
            mtime: now,
            sortKey: newNum > 0 ? Number(newNum + '' + now) : now
        })))
    }
    return null
})

sendToDeviceが実際に送信している処理です。Functionsでは非同期な処理の終了を待つ場合、promiseを返す必要があるので、先のpostQueue更新と合わせてallで返します。これで、端末側にpush通知が届きます。

まとめ

という感じです。他にも、上記を組み合わせれば未読管理とその通知なども実装できます。Firebaseは他にもユーザー認証や静的webサイトのホスティングなど、ゲームを作ってるときに欲しくなるサービスが一通り揃っています。小規模であれば無料枠に収まる範囲で使えるので、是非一度導入してみてはいかがでしょうか。