爆速でwh.imのゲームを実装する(NGワードゲーム編)

# はじめに

こんにちは。普段は情報系の学科で大学生をしている者です。

[この記事](https://qiita.com/aitaro/items/8a97d320f5586c6e7bb6)に紹介されている、[wh.im](https://wh.im)(ウィム)というサービスの立ち上げに関わっているのですが、その一環でwh.im上で楽しめるゲームを開発しました。

このサービスの特徴として、誰でもゲームを投稿 できます！そのやり方を知っていただきたく、前回に引き続き記事を書きますので、興味を持った方はぜひゲーム開発を試してみてください！

前回の[じゃんけん](https://qiita.com/ttttkkkkk31525/items/c0e4eec05d7cb3600c0e)に引き続き、より複雑なゲームを実装していきます。


今回は実際にwh.im上で遊ぶことのできる「NGワードゲーム」を例に挙げて、実装方法を説明いたします。このゲームで出てくる、phaseやグローバルで使える関数などは、前回出てこなかったテクニックとなります。

# まずは開発環境で実際に動かしてみる

実際に[NGワードゲーム](https://github.com/whimRTC/whim-ng_word)を開発環境で動かしてみたいと思います。

まず

```bash

$ cd ダウンロードしたいディレクトリ
$ git clone https://github.com/whimRTC/whim-ng_word.git
```

とします。そして

```bash
$ cd whim-ng_word
$ yarn # or npm install
$ yarn serve # or npm run serve
```

とします。yarnまたはnpmがインストールされていない場合はインストールしてください。
すると`localhost:3001`にゲームが起動します。

そして、[wh.im](https://wh.im)から「遊び場」へ入室し、そのアドレスの末尾に`&develop=true`をつけます。するとwh.imが開発者用のモードとなります。右上のメニューの「アプリを選ぶ」から「開発用(port:3001)」を選ぶことにより、自分の手元でゲームを試すことができます。

![起動画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/365308/0ac3f6ea-0951-03f9-18ad-d31d191fe617.png)


このように表示されれば成功です！

# 実際のコードを見てみる

続いて、実際のコードを使いながら通信方法を説明していきます。

## App

まず表示される画面が書かれている`src/App.vue`をご覧ください。

```vue:src/App.vue
<template>
  <div id="app">
    <Main class="main" />

    <Player
      v-for="user in $whim.users"
      class="player"
      :key="user.id"
      :class="whimUserWindowClass(user)"
      :displayUser="user"
    />
  </div>
</template>

<script>
export default {
  name: "App",
  components: {
    Main: () => import("@/components/main/Index"),
    Player: () => import("@/components/player/Index")
  },
  mounted() {
    // for standalone devtools
    // let recaptchaScript = document.createElement("script");
    // recaptchaScript.setAttribute("src", "http://localhost:8098");
    // document.head.appendChild(recaptchaScript);
  }
};
</script>
<!-- 以下略 -->
```

このゲームではMain画面とPlayer画面に分かれて実装されています。そのため、`App.vue`内で`Main`、`Player`の2つのコンポーネントを呼び出しています。
Mainは画面中央部の画面を、Playerはそれぞれのユーザーのいる場所に表示される画面を表します。
`Main`、`Player`の実装はそれぞれ、 `src/components/main/Index.vue`、`src/components/player/Index.vue`に実装があります。

また、`whimUserWindowClass`はwhim-client-vueに付属するwhimの画面表示に合わせるCSSのクラスを返します。

## wh.imを経由した通信の方法

前回の復習です。
`App.vue`で`$whim.users`という呼び出しがあります(`this.$whim.users`の省略形です)が、これは`whim-client-vue`というパッケージに入っています。このようにすることで、**`this.$whim`から始まる関数だけで、利用者間の非同期通信まわりは全てできるようになっています。**

ここに扱える関数一覧を示します。scriptタグ内では適宜`this`を先頭に付けてください。

### 状態取得(呼び出すたびに通信する)

| コード           | 型     | 説明                           |
| ---------------- | ------ | ------------------------------ |
| $whim.users      | [User] | ルームに入っているユーザー一覧 |
| $whim.room       | Room   | Room Object                    |
| $whim.accessUser | User   | 現在アクセスしているUser       |
| $whim.state      | State  | ゲームの状態(**自由に設計可能**)          |

### 状態変更
| コード                     | 引数   | 説明                                                         |
| -------------------------- | ------ | ------------------------------------------------------------ |
| $whim.assignState(Object)  | Object | ゲーム情報を追記更新、 <br />存在しないキーの場合：追記 <br />存在するキーの場合：更新 |
| $whim.resetState(Object) | Object | ゲーム情報を渡されたObjectにすべて変える                     |

これでは分かりにくいと思うので、後ほどのコードで使っている部分を見ながら、理解していただけると助かります。
より詳細な説明は、[開発者ドキュメント](https://github.com/whimRTC/whim-docs/blob/master/developer/v1.2/whim-client-vue-API.md)をご覧ください。

## このゲームのデータ構造
stateはゲームに合わせて**自由に設計**することができます。
今回のゲームでは、次のような設計です。

```
state
├── phase   // ゲームのフェーズ: "shuffling" | "playing" | "answer" 
└── ngWords // NGワード: {ユーザーID: そのユーザーのNGワード}
```
phaseを用いることで、ゲームの状態を整理しながらコードを書くことができます。

これらの変数は開発環境ではどんな時でも見ることができます。右上のメニューから「SHOW APP STATE」を選択すると

<img width="200" alt="image.png" src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/365308/fa40d7a0-3fc8-348f-f937-3dc1ae5bf894.png />

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/365308/fcc4db7b-a396-e85e-f6a5-173a7c3860ae.png)

白い部分には私のuserIdが表示されています。

このように、現在の`phase`や自分のNGワードを確認することができます(ngWordsは畳み込まれて表示されるので一度クリックして展開してください)。このようにして、カンニングすることができます(友達とやるときはやめましょう)。

## Vueのグローバル変数

wh.imで通信したいときに、別のコンポーネント内で処理を共通化したいことがあると思います。そういったときには、次のように、`main.js`に追記します。

```javascript:src/main.js
const NG_WORD_PATTERNS = require("@/assets/ng_word_patterns.json");

Vue.prototype.$gameStart = () => {
  const shuffledPattern = shuffle(
    NG_WORD_PATTERNS[Math.floor(Math.random() * NG_WORD_PATTERNS.length)]
  );
  let ngWords = {};
  Vue.prototype.$whim.users.forEach((user, i) => {
    ngWords[user.id] = shuffledPattern[i];
  });
  Vue.prototype.$whim.assignState({
    phase: "shuffling",
    ngWords: ngWords
  });
};
```

Vue.jsではvueファイル内で`this.$hoge`で表されるグローバル関数は、`Vue.prototype`に定義されています(whim-client-vueの実装もそのようになっています)。だから、上のように`Vue.prototype.$gameStart`に関数を代入しておけば、vueファイル内で`this.$gameStart`のように呼び出すことができます(Vueには様々な`$`から始まるメソッドがあるのでゲームで使う関数だとわかるように`$game`から始まる関数名にしました)。

また、`Vue.prototype.$whim`でwhim-client-vueの関数を呼び出せるようになっています(この関数定義より前に`Vue.use(whimClientVue, { store });`が必要になります)。

この`$gameStart`という関数には、ゲームの開始時の処理を定義しています。具体的にはランダムにお題を`state.ngWords`に格納して、`state.phase`を`'shuffling'`に切り替えています。

## Player
次にPlayer画面のコードについて説明していきます。Player画面は各プレイヤーの上に表示されます。ここにはゲームの状態に応じて、NGワードを表示するかどうかを変えています。

```vue:src/components/player/Index.vue
<template>
  <div class="container">
    <div v-if="status === 'hidden'" class="card hidden">
      <img :src="require('@/assets/logo.png')" class="img" />
    </div>
    <div v-else-if="status === 'shuffling'" class="card">
      <img :src="require('@/assets/shuffling.gif')" class="shuffling" />
    </div>
    <div v-else-if="status === 'visible'" class="card">
      <span class="text--body text">{{
        appState.ngWords[displayUser.id]
      }}</span>
    </div>
  </div>
</template>
<script>
export default {
  name: "Player",
  props: {
    displayUser: Object // 表示されているUserの情報
  },
  computed: {
    phase() {
      return this.$whim.state.phase;
    },
    isMe() {
      return this.displayUser.id === this.$whim.accessUser.id;
    },
    appState() {
      return this.$whim.state;
    },
    status() {
      if (this.phase === "shuffling") {
        return "shuffling";
      }
      if ((this.phase === "playing" && !this.isMe) || this.phase === "answer") {
        return "visible";
      }
      return "hidden";
    }
  }
};
</script>
<!-- 以下略 -->
```

`computed`の`status`で何を画面に表示すべきかを決めています。`status`が`'hidden'`の場合にはNGワードが隠れている状態が表示され、`status`が`'shuffling'`の場合にはシャッフルの演出がされ、`status`が`'visible'`の場合には答えが表示されます。

## Main

Main画面は画面中央部に表示されます。`state.phase`によって表示するコンポーネントを切り替えています。

```vue:src/components/main/Index.vue
<template>
  <div>
    <Shuffling v-if="phase === 'shuffling'" />
    <Playing v-else-if="phase === 'playing'" />
    <Answer v-else-if="phase === 'answer'" />
    <GenreSelection v-else />
  </div>
</template>
<script>
export default {
  name: "Main",
  components: {
    GenreSelection: () => import("@/components/main/GenreSelection"),
    Shuffling: () => import("@/components/main/Shuffling"),
    Playing: () => import("@/components/main/Playing"),
    Answer: () => import("@/components/main/Answer")
  },
  computed: {
    phase() {
      return this.$whim.state.phase;
    }
  }
};
</script>
<style lang="scss" scoped></style>
```

## GenreSelection
ここではゲームのスタート画面を定義しています。ジャンル選択は未実装です。クリックすることで`start`関数が呼ばれます。`start`関数の内部で先程定義した`$gameStart`関数が呼ばれます。

```vue:src/components/main/GenreSelection.vue
<template>
  <div>
    <a class="fuwatto_btn_yellow" @click="start">スタート</a>
  </div>
</template>

<script>
import Mixin from "@/mixins";

export default {
  name: "GenreSelection",
  props: {
    msg: String
  },
  data() {
    return {
      genre: "random"
    };
  },
  methods: {
    start() {
      console.log("start");
      this.gameStart();
    }
  },
  mixins: [Mixin]
};
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped>
.fuwatto_btn_yellow {
  display: block;
  background-color: #ffc60580;
  color: #fff;
  margin: 0px auto;
  padding: 0.5em;
  text-decoration: none;
  border-radius: 4px;
  box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12), 0 1px 5px 0 rgba(0, 0, 0, 0.12),
    0 3px 1px -2px rgba(0, 0, 0, 0.2);
  transition: 0.3s ease-out;
  position: relative;
  width: 140px;
  text-align: center; /*一応BOX内の文字も中央寄せ*/
}
.fuwatto_btn_yellow:hover {
  cursor: pointer;
  text-decoration: none;
  box-shadow: 0 5px 10px 0 rgba(0, 0, 0, 0.12), 0 3px 20px 0 rgba(0, 0, 0, 0.12),
    0 5px 6px -2px rgba(0, 0, 0, 0.2);
}
</style>

<!-- 以下略 -->
```

## Shuffling
ここではお題をシャッフルしている画面を定義しています。`mounted`関数は、このコンポーネントが表示されたとき呼ばれ、2000ミリ秒後に`state.phase`を`'playing'`に切り替えています。

```vue:src/components/main/Shuffling.vue
<template>
  <div>
    <a class="fuwatto_btn_yellow">シャッフル中...</a>
  </div>
</template>

<script>
export default {
  name: "Shuffling",
  mounted() {
    setTimeout(() => {
      this.$whim.assignState({
        phase: "playing"
      });
    }, 2000);
  }
};
</script>
<!-- 以下略 -->
```

## Playing
ここでは残り時間を中央に表示しています。NGワードやALLシャッフルをクリックすると`goAnswer`関数が呼ばれ、`state.phase`を`answer`に切り替えます。

```vue:src/components/main/Playing.vue
<template>
  <div class="container">
    <countdown
      :time="10 * 60 * 1000"
      @end="goAnswer"
      class="countdown"
      :transform="transform"
      ref="countdown"
    >
      <template slot-scope="props"
        >{{ props.minutes }}:{{ props.seconds }}</template
      >
    </countdown>
    <div class="fuwatto_btn yellow" @click="goAnswer">終了！</div>
  </div>
</template>

<script>
export default {
  name: "Playing",

  methods: {
    goAnswer() {
      this.$refs.countdown.abort();
      this.$whim.assignState({
        phase: "answer"
      });
    },

    transform(props) {
      props.seconds = props.seconds.toString().padStart(2, "0");
      return props;
    }
  }
};
</script>
<!-- 以下略 -->
```

## Answer
答えを表示するフェーズですが、中央にはシャッフルで始めに戻るようにしています。`start`関数で`$gameStart`が呼ばれます。

```vue:src/components/main/Answer.vue
<template>
  <div>
    <a class="fuwatto_btn_yellow" @click="start">シャッフル開始</a>
  </div>
</template>

<script>
import Mixin from "@/mixins";

export default {
  name: "Answer",
  methods: {
    start() {
      this.gameStart();
    }
  },
  mixins: [Mixin]
};
</script>
<!-- 以下略 -->
```

# 最後に

いかがでしたでしょうか。自分でゲームを作れるような気がしてきましたか？
引き続き、ゲーム作りのTipsのようなものは投稿し続けたいと思いますので、よろしくお願いします！