【制作物共有】反応速度計測ゲーム
制作日
2022年3月6日
使用しているプラグインなど
・Nuxt
・Typescript
・Firebase
・@nuxtjs/firebase
・vuex-persistedstate
制作について
今回の制作の目的は「Firebaseを使った何かを作成すること」です。
今から約一年ほど前に初めて案件でFirebaseを使って以来、使う機会がなかったので復習も兼ねてFirebaseを使ったゲームを作成しようと思いました。
ゲームの内容をこだわるよりかは、Firebaseの使い方やNuxtとの連携の仕方などを思い出す方に重きを置いて実装していきます。
完成したゲームサイト
考えた実装・ゲーム内容
実装する機能
・ログイン機能
→FirestoreでユーザーID管理する
・同一ユーザー名は登録させない
→ランキングで正常に表示されないのを防ぐ
・結果タイムによってコメントを分岐させる
→何度でも楽しめるようにしたい
・ランキング機能
→Firestoreで結果タイムを管理する
・再挑戦機能
→LocalStorageを使い同じユーザー名で再挑戦をできるようにする
・ツイート機能
→結果タイムを自動で取得し、ツイートできるようにする
・ユーザーID変更機能
→ユーザーIDを変更し、別IDで登録できるようにする
Firebaseで管理する情報
・ユーザID
・計測結果タイム
ゲーム内容
・クリックだけで達成できる簡単なもの
→反応速度を測るゲーム。
・詳細
→円を4つ表示し、ランダムで一つを赤色に変更する、
赤色で表示された円をクリックするまでのタイムを計測する。
ゲーム内容参考
上記を基に実装をしていきます。
Nuxtの設定
@nuxtjs/firebaseをインストールする
npm install --save-dev @nuxtjs/firebase
nuxt.config.tsに@nuxtjs/firebaseの設定を追記する
// 省略
:
// Modules: https://go.nuxtjs.dev/config-modules
modules: [
[
'@nuxtjs/firebase',
{
config: {
apiKey: 'hogehoge',
authDomain: 'hogehoge.firebaseapp.com',
projectId: 'hogehoge',
storageBucket: 'hogehoge.appspot.com',
messagingSenderId: 'hogehoge',
appId: 'hogehoge',
measurementId: 'hogehoge'
},
services: {
firestore: true // firestoreを使用する場合はtrue
}
}
]
],
// 省略
:
tsconfig.jsonにも@nuxtjs/firebaseの設定を追記
// 省略
:
"types": ["@nuxt/types", "@types/node", "@nuxtjs/firebase"]
// 省略
:
共通コンポーネントの作成
モーダルと画像はコンポーネント化しておきます。
AppModal.vue
<template>
<transition name="modal" appear>
<div class="modal__overlay">
<div class="modal__window">
<div class="modal__content">
<slot />
</div>
</div>
</div>
</transition>
</template>
<style lang="scss" scoped>
@import '~/assets/styles/components/_modal';
</style>
アニメーションはcssでつけます。
_modal.scss
// transition
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.4s;
.modal__window {
transition: opacity 0.4s, transform 0.4s;
}
body {
overflow: scroll;
}
}
.modal-leave-active {
transition: opacity 0.6s ease 0.4s;
}
.modal-enter,
.modal-leave-to {
opacity: 0;
.modal__window {
opacity: 0;
transform: translateY(-20px);
}
body {
overflow: scroll;
}
}
AppPicture.vue
<template>
<picture>
<source :media="`(max-width: ${breakPointSp}px)`" :srcset="`${getSpImg}`" />
<img
decoding="async"
:src="`${getPcImg}`"
:srcset="`${getPcImgRetina} 2x`"
:alt="alt"
:role="role"
:width="width"
:height="height"
/>
</picture>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
pcImg: {
type: String,
required: true,
},
pcImgRetina: {
type: String,
required: true,
},
spImg: {
type: String,
required: true,
},
alt: {
type: String,
required: false,
},
role: {
type: String,
required: false,
},
width: {
type: Number,
required: true,
},
height: {
type: Number,
required: true,
},
},
computed: {
breakPointSp: () => 768,
getPcImg(): any {
return require(`~/assets/img/pc/${this.pcImg}`);
},
getPcImgRetina(): any {
return require(`~/assets/img/pc/${this.pcImgRetina}`);
},
getSpImg(): any {
return require(`~/assets/img/sp/${this.spImg}`);
},
},
});
</script>
Localstorageの設定
vuex-persistedstateのインストール
npm install --save-dev vuex-persistedstate
plugns/localStorage.ts
import createPersistedState from 'vuex-persistedstate';
export default ({ store }) => {
createPersistedState({})(store);
};
nuxt.config.ts
// 省略
:
// Plugins to run before rendering page (https://go.nuxtjs.dev/config-plugins)
plugins: [
{ src: '~/plugins/localStorage', mode: 'client' },
],
// 省略
:
これでローカルストレージを使えるようになります。
store/index.tsの設定
状態管理するものを設定します
index.ts
export const state = () => ({
resultData: {
id: ''
},
speedTime: '',
changeIdFlg: false,
modalFirstFlg: true,
modalFlg: false,
resultNormalFlg: false,
});
export const mutations = {
setInfo(state, payload) {
state.resultData.id = payload.id;
},
changeIdFlg(state, payload) {
state.changeIdFlg = payload.changeIdFlg;
},
speedTime(state, payload) {
state.speedTime = payload.speedTime;
},
modalFirstFlg(state, payload) {
state.modalFirstFlg = payload.modalFirstFlg;
},
modalFlg(state, payload) {
state.modalFlg = payload.modalFlg;
},
resultNormalFlg(state, payload) {
state.resultNormalFlg = payload.resultNormalFlg;
}
};
export const actions = {
setInfo({ commit }, payload) {
commit('setInfo', {
id: payload.id
});
},
speedTime({ commit }, payload) {
commit('speedTime', {
speedTime: payload.speedTime
});
},
changeIdFlg({ commit }, payload) {
commit('changeIdFlg', {
changeIdFlg: payload.changeIdFlg
});
},
modalFirstFlg({ commit }, payload) {
commit('modalFirstFlg', {
modalFirstFlg: payload.modalFirstFlg
});
},
modalFlg({ commit }, payload) {
commit('modalFlg', {
modalFlg: payload.modalFlg
});
},
resultNormalFlg({ commit }, payload) {
commit('resultNormalFlg', {
resultNormalFlg: payload.resultNormalFlg
});
}
};
export const getters = {
resultData: (state) => {
return state.resultData;
},
speedTime: (state) => {
return state.speedTime;
},
changeIdFlg: (state) => {
return state.changeIdFlg;
},
modalFirstFlg: (state) => {
return state.modalFirstFlg;
},
modalFlg: (state) => {
return state.modalFlg;
},
resultNormalFlg: (state) => {
return state.resultNormalFlg;
}
};
middlewareの設定
storeにユーザーIDの情報がない場合、ログインページへリダイレクトさせます。
middleware/authenticated.ts
export default function({ store, redirect }) {
// ユーザーが認証されていない場合
if (!store.state.userInfo.id) {
return redirect('/');
}
}
ログイン機能の実装
ログイン機能に必要なものは
・モーダル
・ユーザーIDの入力チェック
・Firestoreに既に同一ユーザーIDがないかチェックする、存在する場合はalertを表示する
・FirestoreにユーザーIDを格納する
です。順番に実装します。
TheLogin.vue
モーダル
<template>
<section class="login">
<AppModal v-show="modalFirstFlg">
<div class="login__inner">
<p class="login__text">
<span class="login__text--bold">ルール説明</span><br />
・スタートボタンを押すと、4つの円のうち一つが赤色に変化します。<br />
・色が変化した円をできるだけ早くクリックしてください。<br />
</p>
<p class="login__text">
<span class="login__text--bold">注意事項</span><br />
・Chrome、Safari推奨。プライベートウィンドウ非推奨。<br />
・ユーザーIDはひらがな、カタカナ、漢字のいずれかでご入力ください。<br />
・入力したユーザーIDはランキングに表示されるのでご注意ください。<br />
・結果はサイト制作者による独断のものになります。 ご了承ください。<br />
</p>
<div class="login__id">
<p class="login__text-id">ユーザーID</p>
<input type="text" v-model="user.id" class="login__input" placeholder="田中 太郎" maxlength="20" data-id oncopy="return false" onpaste="return false" @keyup="inputCheck" />
</div>
<div class="login__button">
<button type="submit" class="login__submit" data-submit disabled @click="submitId">ユーザーIDを登録する</button>
</div>
</div>
</AppModal>
</section>
</template>
<style lang="scss" scoped>
@import '~/assets/styles/components/_login';
</style>
store/index.tsでmodalFirstFlgの状態を管理しています。
ユーザーIDの入力チェック
NGワードや空欄チェックを行います。
上記が含まれる場合は送信ボタンを押せないように設定します。
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
// 省略
:
methods: {
inputCheck() {
//ユーザーIDの空欄チャック
const inputId = this.elm.id.value;
//禁止文字の配列
const ngWord = [
'死',
'ばか',
:
:
];
//正規表現
const regex = new RegExp(ngWord.join('|'));
if (inputId == '' || inputId.match(regex) != null || inputId.match(/[^ぁ-んァ-ヶ一-龠]+/)) {
this.elm.submitButton.disabled = true;
return false;
} else {
this.elm.submitButton.disabled = false;
}
return true;
},
},
});
</script>
Firestoreに既に同一ユーザーIDがないかチェックする、存在する場合はalertを表示する
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
// 省略
:
methods: {
inputCheck() {
//ユーザーIDの空欄チャック
const inputId = this.elm.id.value;
//禁止文字の配列
const ngWord = [
'死',
'ばか',
:
:
];
//正規表現
const regex = new RegExp(ngWord.join('|'));
if (inputId == '' || inputId.match(regex) != null || inputId.match(/[^ぁ-んァ-ヶ一-龠]+/)) {
this.elm.submitButton.disabled = true;
return false;
} else {
this.elm.submitButton.disabled = false;
}
return true;
},
// 以下追記
submitId(): void {
try {
// firestoreのuserInfoコレクションを選択
const userDataCollection = this.$fire.firestore.collection('userInfo');
userDataCollection
// 入力されたユーザーIDがfirestoreに存在するかチェック
.where('id', '==', this.user.id)
.get()
.then((querySnapshot) => {
// firestoreにユーザーIDが存在しない場合、answer()を実行
if (querySnapshot.empty) {
this.answer();
} else {
// 既にfirestoreにユーザーIDが存在する場合、alertを表示
alert('既に存在するユーザーIDになります。別のユーザーIDで開始してください。');
}
});
} catch (e) {
console.log(e);
alert('テータの取得に失敗しました。お手数ですが、リロードしてやり直してください。');
}
},
},
});
</script>
FirestoreにユーザーIDを格納する
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
// 省略
:
methods: {
inputCheck() {
//ユーザーIDの空欄チャック
const inputId = this.elm.id.value;
//禁止文字の配列
const ngWord = [
'死',
'ばか',
:
:
];
//正規表現
const regex = new RegExp(ngWord.join('|'));
if (inputId == '' || inputId.match(regex) != null || inputId.match(/[^ぁ-んァ-ヶ一-龠]+/)) {
this.elm.submitButton.disabled = true;
return false;
} else {
this.elm.submitButton.disabled = false;
}
return true;
},
submitId(): void {
try {
// firestoreのuserInfoコレクションを選択
const userDataCollection = this.$fire.firestore.collection('userInfo');
userDataCollection
// 入力されたユーザーIDがfirestoreに存在するかチェック
.where('id', '==', this.user.id)
.get()
.then((querySnapshot) => {
// firestoreにユーザーIDが存在しない場合、answer()を実行
if (querySnapshot.empty) {
this.answer();
} else {
// 既にfirestoreにユーザーIDが存在する場合、alertを表示
alert('既に存在するユーザーIDになります。別のユーザーIDで開始してください。');
}
});
} catch (e) {
console.log(e);
alert('テータの取得に失敗しました。お手数ですが、リロードしてやり直してください。');
}
},
// 以下追記
answer() {
try {
// useInfoコレクションにユーザーIDを格納する
this.$fire.firestore
.collection('userInfo')
.add({
id: this.user.id
})
.then((ref: any) => {
console.log('Add ID: ', ref.id);
});
//storeに登録情報を送信する
this.$store.dispatch('setInfo', {
id: this.user.id
});
// ユーザーID変更ボタンを表示する
this.$store.dispatch('changeIdFlg', {
changeIdFlg: true
});
// ログインモーダルを閉じる
this.$store.dispatch('modalFirstFlg', {
modalFirstFlg: false
});
} catch (e) {
console.log(e);
alert('テータの送信に失敗しました。お手数ですが、リロードしてやり直してください。');
}
}
},
});
</script>
ゲーム画面の実装
円を表示する
TheBalls.vue
<template>
<section class="balls-area">
<div class="balls-area__inner">
<div class="balls-area__list">
<div class="balls-area__item">
<button class="balls-area__circle" data-button></button>
</div>
</div>
<div class="balls-area__button">
<button class="balls-area__start" @click="start" v-show="flg.startFlg">スタート!</button>
</div>
</div>
<p class="balls-area__text" ref="watchArea"></p>
<p class="balls-area__text">ユーザーID: {{ resultData.id }} さん</p>
<button @click="resetLocalStorage" v-show="changeIdFlg" class="balls-area__change">ユーザーIDを変更する</button>
</section>
</template>
結果に応じて分岐させる
<script lang="ts">
import Vue from 'vue';
import { Buttons } from '~/types/button';
export default Vue.extend({
data(): {
elm: {
buttons: any;
};
randomNumber: number;
buttonSelected: Array<Buttons>;
} {
return {
elm: {
buttons: null
},
randomNumber: 3,
buttonSelected: []
};
},
computed: {
resultData() {
return this.$store.getters['resultData'];
},
changeIdFlg() {
return this.$store.getters['changeIdFlg'];
}
},
mounted() {
this.init();
},
methods: {
// 省略
:
resultTime = getNowTime();
// 結果によって表示画像を分岐させる
if (resultTime < 150) {
this.$store.dispatch('resultRobotFlg', {
resultRobotFlg: true
});
}else {
this.$store.dispatch('resultSlowFlg', {
resultSlowFlg: true
});
}
// 省略
:
},
});
</script>
IDが一致するFirestoreドキュメントに結果タイムを追加する
<script lang="ts">
import Vue from 'vue';
import { Buttons } from '~/types/button';
export default Vue.extend({
data(): {
elm: {
buttons: any;
};
randomNumber: number;
buttonSelected: Array<Buttons>;
} {
return {
elm: {
buttons: null
},
randomNumber: 3,
buttonSelected: []
};
},
computed: {
resultData() {
return this.$store.getters['resultData'];
},
changeIdFlg() {
return this.$store.getters['changeIdFlg'];
}
},
mounted() {
this.init();
},
methods: {
// 省略
:
resultTime = getNowTime();
// 結果によって表示画像を分岐させる
if (resultTime < 150) {
this.$store.dispatch('resultRobotFlg', {
resultRobotFlg: true
});
}else {
this.$store.dispatch('resultSlowFlg', {
resultSlowFlg: true
});
}
// 以下追記
// idが一致するドキュメントにresultTimeを追加する
const userDataCollection = this.$fire.firestore.collection('userInfo');
userDataCollection
.where('id', '==', this.$store.state.resultData.id)
.get()
.then((querySnapshot) => {
querySnapshot.forEach((doc) => {
userDataCollection.doc(doc.id).set(
{
time: resultTime
},
{ merge: true }
);
});
})
.catch((error: any) => {
console.log('Error getting documents: ', error);
alert('テータの送信に失敗しました。お手数ですが、リロードしてやり直してください。');
});
// 省略
:
},
});
</script>
赤色をクリックした際に、結果画面を開く。結果タイムをstoreに格納する。
<script lang="ts">
import Vue from 'vue';
import { Buttons } from '~/types/button';
export default Vue.extend({
data(): {
elm: {
buttons: any;
};
randomNumber: number;
buttonSelected: Array<Buttons>;
} {
return {
elm: {
buttons: null
},
randomNumber: 3,
buttonSelected: []
};
},
computed: {
resultData() {
return this.$store.getters['resultData'];
},
changeIdFlg() {
return this.$store.getters['changeIdFlg'];
}
},
mounted() {
this.init();
},
methods: {
// 省略
:
resultTime = getNowTime();
// 結果によって表示画像を分岐させる
if (resultTime < 150) {
this.$store.dispatch('resultNormalFlg', {
resultNormalFlg: true
});
}else {
this.$store.dispatch('resultSlowFlg', {
resultSlowFlg: true
});
}
// idが一致するドキュメントにresultTimeを追加する
const userDataCollection = this.$fire.firestore.collection('userInfo');
userDataCollection
.where('id', '==', this.$store.state.resultData.id)
.get()
.then((querySnapshot) => {
querySnapshot.forEach((doc) => {
userDataCollection.doc(doc.id).set(
{
time: resultTime
},
{ merge: true }
);
});
})
.catch((error: any) => {
console.log('Error getting documents: ', error);
alert('テータの送信に失敗しました。お手数ですが、リロードしてやり直してください。');
});
// 以下追記
// storeにタイム情報を送信する
this.$store.dispatch('speedTime', {
speedTime: result
});
// 結果のモーダルを開く
this.$store.dispatch('modalFlg', {
modalFlg: true
});
// 省略
:
},
});
</script>
結果画面の作成
TheResult.vue
<template>
<section class="result">
<AppModal v-show="modalFlg">
<div class="result__normal" v-show="resultNormalFlg">
<p class="result__text">
あなたの結果は
<span class="result__time">
{{ speedTime }}
</span>
秒でした!
</p>
<div class="result__img">
<AppImage :pc-img="`normal.png`" :pc-img-retina="`normal@2x.png`" :alt="`人並みです。`" :role="``" :width="1070" :height="671" />
</div>
<div class="result__links">
<div class="result__link result__retry" @click="retry">再挑戦</div>
<p class="result__link" @click="tweetNormal">結果をツイートする</p>
<nuxt-link to="/ranking" class="result__link">ランキングを見る</nuxt-link>
</div>
</div>
</AppModal>
</section>
</template>
storeから情報を受け取り、結果のモーダルを表示する
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
computed: {
speedTime() {
return this.$store.getters['speedTime'];
},
modalFlg() {
return this.$store.getters['modalFlg'];
},
resultNormalFlg() {
return this.$store.getters['resultNormalFlg'];
}
},
});
</script>
ツイート機能の実装
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
computed: {
speedTime() {
return this.$store.getters['speedTime'];
},
modalFlg() {
return this.$store.getters['modalFlg'];
},
resultNormalFlg() {
return this.$store.getters['resultNormalFlg'];
}
},
methods: {
// 以下追記
tweetSlow(): void {
const tw_contents = '私の反応速度は' + this.$store.state.speedTime + '秒でした。';
const url = 'https://kaito-takase.dev/reflexes-game/slow/';
window.open()!.location.href = 'https://twitter.com/share?url=' + url + '&text=' + tw_contents + '&count=none&lang=ja';
}
}
});
</script>
再挑戦機能の実装
モーダルの状態をリセットして、ページをリロードさせます。
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
computed: {
speedTime() {
return this.$store.getters['speedTime'];
},
modalFlg() {
return this.$store.getters['modalFlg'];
},
resultNormalFlg() {
return this.$store.getters['resultNormalFlg'];
}
},
methods: {
tweetSlow(): void {
const tw_contents = '私の反応速度は' + this.$store.state.speedTime + '秒でした。';
const url = 'https://kaito-takase.dev/reflexes-game/slow/';
window.open()!.location.href = 'https://twitter.com/share?url=' + url + '&text=' + tw_contents + '&count=none&lang=ja';
},
// 以下追記
modalReset(): void {
this.$store.dispatch('modalFlg', {
modalFlg: false
});
this.$store.dispatch('resultNormalFlg', {
resultNormalFlg: false
});
},
retry(): void {
this.reset();
location.reload();
},
reset(): void {
this.modalReset();
},
}
});
</script>
ランキングページの実装
TheRanking.vue
<template>
<div class="ranking">
<nuxt-link to="/" class="ranking__go-top">Topへ戻る </nuxt-link>
<div class="ranking__wrap">
<h1 class="ranking__title">ランキング</h1>
<ol class="ranking__list">
<li v-for="(ranking, index) in rankings" :key="index" class="ranking__item">
<p class="ranking__text">
位<br class="sp-only" />
{{ ranking.id }}<span class="ranking__text--small">さん</span><br class="sp-only" />
{{ ranking.time }}<span class="ranking__text--small">秒</span>
</p>
</li>
</ol>
</div>
</div>
</template>
firestoreからタイムの昇順で取得し、配列に格納する
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
data(): {
rankings: Array<string>;
} {
return {
rankings: []
};
},
computed: {
resultData() {
return this.$store.getters['resultData'];
}
},
mounted() {
this.init();
},
methods: {
init() {
this.setRanking();
},
setRanking() {
const userDataCollection = this.$fire.firestore.collection('userInfo');
userDataCollection
.orderBy('time')
.get()
.then((snap) => {
const array = [];
snap.forEach((doc) => {
array.push(doc.data());
});
this.rankings = array;
});
}
}
});
</script>
ランキングを彩るために、vue-confetti.jsを実装します。
npm install --save-dev vue-confetti
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
data(): {
rankings: Array<string>;
} {
return {
rankings: []
};
},
computed: {
resultData() {
return this.$store.getters['resultData'];
}
},
mounted() {
this.init();
},
methods: {
init() {
this.setRanking();
this.startConfetti();
},
setRanking() {
const userDataCollection = this.$fire.firestore.collection('userInfo');
userDataCollection
.orderBy('time')
.get()
.then((snap) => {
const array = [];
snap.forEach((doc) => {
array.push(doc.data());
});
this.rankings = array;
});
},
// 以下追記
startConfetti(): void {
this.$confetti.start({
particles: [
{
type: 'rect', //紙吹雪の種類
size: 5, //サイズ 0〜10
dropRate: 4 //スピード 0〜10
}
],
defaultSize: 5,
windSpeedMax: 0, //風の強さ 0〜1
particlesPerFrame: 0.5 //紙吹雪の量 0〜1
});
},
}
});
</script>
これで完成です。
ログイン画面
ログインアラート
ゲーム画面
結果画面
ランキング画面
結果画面の画像はいらすとやをお借りしました。
感想
久しぶりのFirebaseで詰まる部分もありながら、楽しく実装できました。
一年前にはなかった(と思う)@nuxtjs/firebaseはかなり簡単で、よりNuxtとFirebaseでの制作物作成を助けてくれると思いました。
今後も案件で使用する機会は多くはないと思いますが、使える場面ではどんどん使っていきたいと思います。
参考サイト
この記事が気に入ったらサポートをしてみませんか?