※この記事は人狼GM支援ツールを作る(Laravel-マイグレーション編)の続きとなります。
はじめに
前々回、前回とLaravelを導入してDB周りをやっつける所まで進みました。
静的なページはController、Viewをサクっと書くだけで作れてしまうので、それほど難しくありませんでした。
(なので今回は記事にはしません。余裕があれば……)
さて、今回は動的なページを複数作るためにVue.jsと格闘した話です。
以下、各ソフトウェアのバージョンは
- PHP 7.2.5
- Laravel 5.6
- Vue.js
です。
作りたいページ
今回作りたいページは、人狼ゲームを実際にプレイするための画面です。
具体的には、以下のような要件となります。
- 全画面共通
- 処刑者選択機能
- 村人(狂人)画面
- メモ機能
- 人狼画面
- チャット機能
- 襲撃先選択機能
- 占い師画面
- メモ機能
- 占い先選択機能
- 霊能者画面
- メモ機能
- 霊能結果表示
- 共有者画面
- チャット機能
- 狩人画面
- メモ機能
- 護衛先選択機能
選択機能はボタンを押したらモーダルが出て、そこでプレイヤーを一人選んで決定ボタンを押すという流れです。
メモ機能とチャット機能は、LINEのような入力欄を画面下に用意して役職同士のチャット、または個人用のメモを画面に残せる機能となっています。
簡単に言うと、似たような見た目で動作が異なるパーツを沢山用意する必要がある、ということになります。
ところで、人狼GM支援ツールを作るとか言いながら、ゲームの話はここまで一切していませんでしたね。
実装
Vue.jsを実際に書き始める前に、まずはVue.jsがどういったものか説明しておきたいと思います。
Vue (発音は / v j u ː / 、 view と同様)はユーザーインターフェイスを構築するためのプログレッシブフレームワークです。他の一枚板(モノリシック: monolithic)なフレームワークとは異なり、Vue は少しずつ適用していけるように設計されています。中核となるライブラリは view 層だけに焦点を当てています。そのため、使い始めるのも、他のライブラリや既存のプロジェクトに統合するのも、とても簡単です。また、モダンなツールやサポートライブラリと併用することで、洗練されたシングルページアプリケーションの開発も可能です。
※Vue.jsガイドより抜粋
なんだかよくわかりませんが、とりあえず 少しずつ適用していける のが売りのようです。
他にも色々と調べたところ、 データの双方向バインディング と 高速なDOM操作 、 コンポーネント が特徴のフロントフレームワークであるということがわかりました。
AngularやReactと比較してシンプルであるため、学習コストが高くないのも良い点ですね。
今回は様々なパーツを作るため、コンポーネントをポコポコと作っていきたいと思います。
Vue.jsの基礎
実装の解説に入る前に、Vue.jsについてもう少し詳しく解説します。
正直ドキュメントの日本語訳がすごく充実してるので、そちらを読んでいただければこんな記事いらないんですけど
Vue.jsの一番シンプルな使い方は以下のようになります。
<div id="app">
{{ message }}
</div>
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
})
data.message
が{{ message }}
にバインドされ、Hello Vue!
と表示されます。
また、v-modelという
もしdata.message
に変更があると、表示もリアルタイムに変更されます。
また、HTMLタグの属性を拡張するような形でループ、分岐などを制御することができます。
コンポーネント
コンポーネントの作り方もいくつかありますが、今回は「単一ファイルコンポーネント」を利用して作っていきます。
単一ファイルコンポーネントというのは、**見た目と動作を同じファイルに書いてしまおう!**というものです。
私のコンポーネントの実装を例として出したいと思います。
<template>
<div class="input-group">
<input type="text" class="form-control form-control-lg" v-model="message">
<span class="input-group-btn">
<button class="btn btn-primary btn-lg" v-on:click="send">送信</button>
</span>
</div>
</template>
<script>
export default {
name: "BaseChatInput",
data() {
return {
message: null,
}
},
methods: {
send() {
this.$emit('send', this.message);
this.message = "";
},
},
}
</script>
<style scoped>
</style>
<template>
<div class="common-memo">
<base-chat-input v-on:send="memo"></base-chat-input>
</div>
</template>
<script>
import BaseChatInput from "./BaseChatInput";
import store from '../../store.js';
export default {
name: "MemoInput",
components: {BaseChatInput},
props: {
message: String,
},
methods: {
memo(message) {
const memo = {
message: message,
playerName: 'MEMO',
};
store.pushMessageList(memo);
}
}
}
</script>
<style scoped>
</style>
<template>
<div class="text-area">
<div class="message-box" v-for="message in messageList">
<span>
<small>{{message.playerName}} :</small>
<span>{{message.message}}</span>
</span>
</div>
</div>
</template>
<script>
export default {
name: "BaseTextArea",
props: {
messageList: Array,
}
}
</script>
<style scoped>
</style>
<template>
<base-text-area :message-list="state.messageList"></base-text-area>
</template>
<script>
import BaseTextArea from "./BaseTextArea";
import store from '../../store.js';
export default {
name: "CommonTextArea",
components: {BaseTextArea},
data() {
return {
state: store.state,
}
},
mounted() {
store.fetchRoomInfo()
.then(e => this.connect(e))
.then(() => store.startGame());
},
methods: {
async connect(roomId) {
// チャットのチャンネル接続処理
}
}
}
</script>
<style scoped>
</style>
一つのファイルの中にHTML、JS、CSS(今回はbootstrapを利用しているので記述がありませんが、sassなども利用することができます)を記述しています。
また、MemoInputがBaseChatInputを、CommonTextAreaがBaseTextAreaを利用しています。
細かい話は後述しますが、このようにコンポーネントに親子関係を持たせることで同一のコンポーネントに異なる動作を持たせることができます。
今回の例では、MemoInputが親、BaseChatInputが子のコンポーネントとなります。
イベントとコンポーネントの親子関係
さて、ここでは「親子関係」について見ていきます。
ここで注目するのは、子コンポーネントに記述されているv-on:click="send"
と、親コンポーネントのv-on:send="memo"
です。
子コンポーネントのボタンがクリックされると、まずは子コンポーネント内に用意したsend
メソッドが呼び出されます。
methods: {
send() {
this.$emit('send', this.message);
this.message = "";
},
},
メソッド中の$emit('send', this.message)
によって、親コンポーネントにsend
という名前のイベントを引数とともに渡しています。
親コンポーネントはイベントを受け取ると、memo
というメソッドを呼び出します。
methods: {
memo(message) {
const memo = {
message: message,
playerName: 'MEMO',
};
store.pushMessageList(memo);
}
},
親コンポーネントのメソッド内で子から送られた情報を加工し、データストア用のオブジェクトに格納しました。
親コンポーネントでの処理を変えれば同じ子コンポーネントを利用した別のコンポーネントを新たに作ることもできますね。
このように、親子関係とデータ、イベントの送受信を用いてコンポーネントを組み合わせ、画面を作っていきます。
storeパターン
Vueが画面にバインドさせたいデータは、基本的にはVueオブジェクト自身が持ちます。
それを子に渡したり、イベントという形で受け取ったりすることでコンポーネント間のデータを受け渡すことになるのですが、それだけではコンポーネントの階層が深くなった際にとても面倒なデータの送受信リレーが必要になってしまいます。
また、親子関係にないコンポーネントにはデータを渡すことができません。
しかし、今回私はチャットの入力欄と表示欄は全くの別コンポーネントとして実装しました。
messageList
という一つのデータを扱いたいのですが、どうしたら簡単に扱うことができるでしょう。
答えは意外と簡単です。Vue.jsもjavascriptなのですから、必要なデータを格納するオブジェクトを用意してやるだけです。
すぐ上にも書いたように、データストアオブジェクトstore
を作り、画面で使うデータはすべてそこに持たせました。
import axios from 'axios';
const store = {
state: {
players: [],
messageList: [],
canStart: false,
},
async fetchRoomInfo() {
// 部屋情報取得
},
fetchLivingPlayer() {
// プレイヤー一覧取得
},
pushMessageList(message) {
this.state.messageList.push(message);
},
startGame() {
if (this.state.canStart) {
// ゲーム開始通知
}
}
};
export default store;
※一部メソッドは省略しています
各コンポーネントはstoreの中身を触るので、データの送受信で悩まされずに、あるコンポーネントで更新したデータを別のコンポーネントに反映させることができるようになりました。
この手法はVue.jsガイドにも記載されています。
このパターンを用いる際は、ストアオブジェクトに対する操作は必ずストアオブジェクト自身のメソッドに行わせるようにしましょう。(でないと、ストアオブジェクトの構造が変わる等の変更に弱くなってしまいます)
まとめ
ここまで、駆け足ではありましたが簡単にVue.jsについて説明しました。
私自身はじめて触れる技術ではあったのですが、非常にわかりやすいドキュメントとシンプルな使い方のおかげで、サクサクと画面を作ることができました。
学習コストの低さもVue.jsのいいところですね。
今回の実装に関しては、storeパターンが役に立ちました。
コンポーネント数が増えて複雑化してきた際はこのパターンの採用を考慮に入れると捗るのではないかと思います。
また、Vue.jsは扱っていてかなり面白かったので、Vue.jsを使ったwebアプリをまた作ってみようと思っています!
人狼GM支援ツールを作るお話としては、次回が最終回になる予定です。
次回はwebsocketでチャット機能を実装するお話をしようと思います。