Firebaseとフロントエンドのみでオンラインゲームを作った話（ソースコード有り）

# はじめに
なんか急にゲームが作りたくなった！！！
→よし作ろう！！！

もっと詳しい動機
・手軽にできるオンラインゲームがやりたい！
→ないじゃん作りたい
・わがままなせいか、現状面白いと思えるゲームがない
→自分で作ればいいじゃん
・頭の中では大規模な設計図がある
→いきなりそんなの無理だよね（実際大規模ゲーム作ろうとして挫折した経験ありツクールやUnityだったが・・・。）

　→まずはオンラインゲームのベースを探そう（お金掛けたくないしFirebaseがいいな）
　　→探したけど無い！！ならば俺が！

# 使った技術
Firebase（RealtimeDatabase）とVue.jsで作っています
Cloud FireStoreでも同じことが出来ると思います

RealtimeDatabase/Cloud FireStoreを使えばフロントエンドのみで、オンラインゲームが出来ますよー！ということですね

# 作ったもの
ただのじゃんけんゲームです（期待させてすんません）
一応、多人数対応しています！！！
https://onlinejyanken.web.app

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/911989/cb527aff-0a46-506b-d1ea-5e4c95cf0ffb.png)


# 目次
<!-- タイトルとアンカー名を編集 -->
1. [ソースコード](#ソースコード)
1. [解説](#解説)

<!-- 各チャプター -->
<a id="#ソースコード"></a>
# ソースコード

実際は単一コンポーネント(.vue)ですがQiitaではハイライトがつかないため、HTMLとJavascriptを分割しています

```html:index.vue
<template>
  <div>
    <p>じゃんけんリスト</p>
    <div v-for="(res,index) in jyankenList" :key="index">
      <div class="d-flex">
        <div style="width:50px; height:50px;">
          <img class="fill" src="/assets/dasumae.png" alt />
        </div>
        <b>{{res.name}}</b>
      </div>
    </div>
    <p>参加者リスト</p>
    <div v-for="(res,index) in userList" :key="index">
      <div>
        <b>{{res.name}}</b>
      </div>
    </div>
    <!-- <input type="text" id="txb-room" v-model="roomId" class="form-control" placeholder="ルーム番号" /> -->
    <input type="text" id="txb-name" v-model="name" class="form-control" placeholder="名前" />
    <button
      v-on:click="join(name)"
      id="btn-join"
      type="button"
      class="btn waves-effect waves-light orange darken-1"
    >参加</button>

    <div id="btn-list" class="d-flex justify-content-around">
      <button
        disabled="true"
        class="btn waves-effect waves-light white"
        style="width:100px; height:100px; padding:0;"
        v-on:click="putOut(0,name)"
      >
        <img class="fill" src="/assets/gu-.png" alt />
      </button>
      <button
        disabled="true"
        class="btn waves-effect waves-light white"
        style="width:100px; height:100px; padding:0;"
        v-on:click="putOut(1,name)"
      >
        <img class="fill" src="/assets/tyoki.png" alt />
      </button>
      <button
        disabled="true"
        class="btn waves-effect waves-light white"
        style="width:100px; height:100px; padding:0;"
        v-on:click="putOut(2,name)"
      >
        <img class="fill" src="/assets/pa-.png" alt />
      </button>
    </div>
    <p>結果！</p>
    <div v-for="(res,index) in jyankenListResultView" :key="index">
      <div class="d-flex">
        <div style="width:50px; height:50px;">
          <img class="fill" :src="res.img" alt />
        </div>
        <b>{{res.name}}</b>
      </div>
    </div>
    <h2 style="color:red;">{{result}}</h2>
  </div>
</template>
```

```javascript:index.vue
function uuid() {
  var uuid = "",
    i,
    random;
  for (i = 0; i < 32; i++) {
    random = (Math.random() * 16) | 0;

    if (i == 8 || i == 12 || i == 16 || i == 20) {
      uuid += "-";
    }
    uuid += (i == 12 ? 4 : i == 16 ? (random & 3) | 8 : random).toString(16);
  }
  return uuid;
}
const myId = uuid();

import firebase from "firebase";
import { defineComponent } from "vue";
export default defineComponent({
  name: "App",
  props: {
    roomId: String
  },
  data() {
    return {
      name: "",
      //DBと紐付けるじゃんけんリスト
      jyankenList: [],
      //DBと紐付ける参加ユーザー一覧
      userList: [],
      result: "",
      //結果表示用じゃんけんリスト
      jyankenListResultView: []
    };
  },
  mounted() {
    let vm = this;
    //ユーザーのリスナー
    let key = "joinUser/" + this.roomId;
    firebase
      .database()
      .ref(key)
      .on("child_added", snap => {
        this.userList.push(snap.val());
        if (this.userList.length >= 2) {
          this.changeBtnDisable(false);
          this.deletePutOut(this.roomId);
        }
      });
    //ユーザーのリスナー
    firebase
      .database()
      .ref(key)
      .on("child_removed", snap => {
        let data = snap.val();
        let temp = this.userList.filter(x => x.userId != data.userId);
        this.userList = [];
        this.userList = temp;
      });
    //ジャンケンポンのリスナー
    let key2 = "jyanken/" + this.roomId;
    firebase
      .database()
      .ref(key2)
      .on("child_added", snap => {
        this.jyankenList.push(snap.val());
        if (this.judge(this.jyankenList, this.userList)) {
          this.viewResult(this.jyankenList);
          this.result = this.culcJankenMain(this.jyankenList);
          this.jyankenList = [];
          this.changeBtnDisable(false);
        }
      });
  },
  methods: {
    //ジャンケンポンの計算をするか
    judge: function(jyankenList, userList) {
      let jankenUserIdList = jyankenList.map(x => x.userId);

      for (let user of userList) {
        if (jankenUserIdList.includes(user.userId) == false) {
          return false;
        }
      }
      return true;
    },
    //じゃんけんのメイン（計算メソッドを呼んで結果を表示）
    culcJankenMain: function(jyankenList) {
      let theyPutOut = jyankenList.filter(x => x.userId != myId);
      let myPutOut = jyankenList.filter(x => x.userId == myId)[0];

      let res = this.culcJanken(
        myPutOut.value,
        theyPutOut.map(x => x.value)
      );
      switch (res) {
        case 1:
          return "勝ち！";

        case -1:
          return "負け！";

        case 0:
          return "あいこ";
      }
    },
    //結果表示（じゃんけん一覧）
    viewResult: function(jyankenList) {
      for (let jyanken of jyankenList) {
        let temp = jyanken;
        temp["img"] = this.getImg(temp.value);
        this.jyankenListResultView.push(temp);
      }
    },
    getImg: function(num: number) {
      switch (num) {
        case 0:
          return "/assets/gu-.png";
        case 1:
          return "/assets/tyoki.png";
        case 2:
          return "/assets/pa-.png";
      }
    },
    //ユーザー参加
    join: function(name: string) {
      document.querySelector("#btn-join").disabled = true;
      document.querySelector("#txb-name").disabled = true;

      let key = "joinUser/" + this.roomId + "/" + myId;
      var ref = firebase.database().ref(key);

      let obj = {
        userId: myId,
        datetime: new Date().toLocaleString(),
        name: name
      };

      ref.set(obj);
    },
    //じゃんけんぽん
    putOut: function(your: number, name: string) {
      this.jyankenListResultView = [];
      let key = "jyanken/" + this.roomId;
      var ref = firebase
        .database()
        .ref(key)
        .push();

      let obj = {
        jyankenId: uuid(),
        value: your,
        datetime: new Date().toLocaleString(),
        userId: myId,
        //RDB脳な方はおかしいだろって思うかもしませんが、RealtimeDatabaseでは助長化が常識です
        name: name
      };

      this.changeBtnDisable(true);
      ref.set(obj);
    },
    //じゃんけんのデータをDBから消す
    deletePutOut: function(roomId) {
      let key = "jyanken/" + roomId;
      var updates = {};
      updates[key] = null;
      firebase
        .database()
        .ref()
        .update(updates);
    },

    //じゃんけんの計算
    //ホスト・クライアントのシステムであれば、ホストのみで計算をさせるべきだが
    //ホストが計算して各クライアントに返すと通信量が増えたり、動作が遅くなったりすることを考えると
    //各クライアントが計算するのでもいっか！という感じ
    //二人以上のじゃんけんにも対応
    culcJanken: function(your: number, opponentList: number[]) {
      let allList = [];
      allList.push(your);
      allList = allList.concat(opponentList);

      let kindCount = 0;

      let opponent = -1;
      if (allList.includes(0)) {
        if (your != 0) {
          opponent = 0;
        }
        kindCount++;
      }
      if (allList.includes(1)) {
        if (your != 1) {
          opponent = 1;
        }
        kindCount++;
      }
      if (allList.includes(2)) {
        if (your != 2) {
          opponent = 2;
        }
        kindCount++;
      }

      if (kindCount != 2) {
        return 0;
      }

      if (your == 0 && opponent == 1) {
        return 1;
      } else if (your == 1 && opponent == 2) {
        return 1;
      } else if (your == 2 && opponent == 0) {
        return 1;
      }
      return -1;
    },
    changeBtnDisable: function(disabled) {
      Array.prototype.map.call(
        document.querySelector("#btn-list").querySelectorAll("button"),
        function(i) {
          i.disabled = disabled;
        }
      );
    },
    //ページ遷移時のハンドラ
    //参加者から自分を消す
    transitionPage(event) {
      let key = "joinUser/" + this.roomId + "/" + myId;
      var ref = firebase.database().ref(key);
      var updates = {};
      updates[key] = null;
      firebase
        .database()
        .ref()
        .update(updates);
    }
  },
  created() {
    window.addEventListener("beforeunload", this.transitionPage);
  },
  destroyed() {
    window.removeEventListener("beforeunload", this.transitionPage);
  }
});
```


<a id="#解説"></a>
# 解説

ソースコード上にがっつりコメント書いたので、あまり言うことはないのですが
ルートpropでルームIDを受け取って、そこのルームでじゃんけんするって感じです

以下、簡単な流れです

1. 参加する
1. 参加者が二人以上でじゃんけんボタン有効化
1. じゃんけん！
1. 各クライアントのじゃんけんをイベントでリッスン
1. 参加者全員がじゃんけんを出し終わったら計算
1. 勝ち負け表示！


その他にも、途中脱離した場合は参加者一覧から消すなどもしています
（そうでないと、全員出し終わらない状態が続いてどうにもならなくなるため）

色々ガバガバですが、一応オンラインゲームのベースになるんではないでしょうか・・・

ガバガバリスト
・各クライアントで結果計算しているため齟齬が発生する場合がある。（本来、ホスト・クライアントでは、ホストのみが計算してクライアントに返すべき。ただこの方法だと無駄な通信が発生するので嫌だなと）
・じゃんけんの結果を生値で返しているので、開発者ツールで値覗けば相手がだしたのが何かわかる
・ゲーム終わった後DB消す処理がないので、ゴミデータが残る（参加者が一人の場合ページ遷移したらルームデータ全消しでいいのかも。）
・セキュリティがあれ（RealtimeDatabaseのルール設定ちゃんとすればいくらかは・・・）
等。ほかにもあるかも