はじめに
こんにちは。普段は情報系の学科で大学生をしている者です。
この記事に紹介されている、wh.im(ウィム)というサービスの立ち上げに関わっているのですが、その一環でwh.im上で楽しめるゲームを開発しました。
このサービスの特徴として、誰でもゲームを投稿 できます!そのやり方を知っていただきたく、前回に引き続き記事を書きますので、興味を持った方はぜひゲーム開発を試してみてください!
前回のじゃんけんに引き続き、より複雑なゲームを実装していきます。
今回は実際にwh.im上で遊ぶことのできる「NGワードゲーム」を例に挙げて、実装方法を説明いたします。このゲームで出てくる、phaseやグローバルで使える関数などは、前回出てこなかったテクニックとなります。
まずは開発環境で実際に動かしてみる
実際にNGワードゲームを開発環境で動かしてみたいと思います。
まず
$ cd ダウンロードしたいディレクトリ
$ git clone https://github.com/whimRTC/whim-ng_word.git
とします。そして
$ cd whim-ng_word
$ yarn # or npm install
$ yarn serve # or npm run serve
とします。yarnまたはnpmがインストールされていない場合はインストールしてください。
するとlocalhost:3001
にゲームが起動します。
そして、wh.imから「遊び場」へ入室し、そのアドレスの末尾に&develop=true
をつけます。するとwh.imが開発者用のモードとなります。右上のメニューの「アプリを選ぶ」から「開発用(port:3001)」を選ぶことにより、自分の手元でゲームを試すことができます。
このように表示されれば成功です!
実際のコードを見てみる
続いて、実際のコードを使いながら通信方法を説明していきます。
App
まず表示される画面が書かれている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 | ゲーム情報を追記更新、 存在しないキーの場合:追記 存在するキーの場合:更新 |
$whim.resetState(Object) | Object | ゲーム情報を渡されたObjectにすべて変える |
これでは分かりにくいと思うので、後ほどのコードで使っている部分を見ながら、理解していただけると助かります。
より詳細な説明は、開発者ドキュメントをご覧ください。
このゲームのデータ構造
stateはゲームに合わせて自由に設計することができます。
今回のゲームでは、次のような設計です。
state
├── phase // ゲームのフェーズ: "shuffling" | "playing" | "answer"
└── ngWords // NGワード: {ユーザーID: そのユーザーのNGワード}
phaseを用いることで、ゲームの状態を整理しながらコードを書くことができます。
これらの変数は開発環境ではどんな時でも見ることができます。右上のメニューから「SHOW APP STATE」を選択すると
白い部分には私のuserIdが表示されています。
このように、現在のphase
や自分のNGワードを確認することができます(ngWordsは畳み込まれて表示されるので一度クリックして展開してください)。このようにして、カンニングすることができます(友達とやるときはやめましょう)。
Vueのグローバル変数
wh.imで通信したいときに、別のコンポーネント内で処理を共通化したいことがあると思います。そういったときには、次のように、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ワードを表示するかどうかを変えています。
<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
によって表示するコンポーネントを切り替えています。
<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
関数が呼ばれます。
<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'
に切り替えています。
<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
に切り替えます。
<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
が呼ばれます。
<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のようなものは投稿し続けたいと思いますので、よろしくお願いします!