皆さんコロナ禍でいかがおすごしでしょうか。
「結構暇してるんだよねー」
「仕事がなくなってえらいことになってる...」
状況は人によって様々かと思いますが、私は割と前者です。
なので暇な時間を使ってなにか作りたいなーと考えていたんですが、その内容が
- ゲーム作りたいなー
- フロントエンドエンジニアとしてなんか他人に見せれる自分のプロダクトほしいなー
- 最近、nuxt(Vueの進化版フレームワーク的なやつ)が流行ってるらしいから勉強してみたいなー
- firebase(firestore)ってリアルタイムで同期できるんだ。なんか面白そうだしこれで面白いもの作れないかなー
なんてことをぼーっと考えていた、そんなある日...
昔、ファイヤーエンブレムってゲーム好きだったけど、あれの対戦ゲームってないんだよなー。あれ、もしかしてfirebase使ったらサーバーなしでPvPのファイヤーエンブレムみたいなゲーム作れるんじゃない?だってリアルタイムに同期できるんでしょ?
あ、じゃあそれでフロント側にnuxt使ったらnuxtの勉強もできるし、なんだったら他の人に見せれるようなものができるかもしれない!まさに一石三鳥!
なんて閃きが湧いてきて、早速やってみることにしました。
ちなみに私のスペックですが、
- フロントエンドエンジニア3年目
- vueは仕事で計3ヶ月ぐらい使っていた
- firebaseは以前ちょっとだけ触ったことがある
- バックエンドの知識はほとんど無し
って感じです。qiita初投稿。
完成品
ということで早速完成品はこちら。
コードはこっち。
https://github.com/koheiobe/spa-simulation-game
挙動の確認はchromeでしか行なっていません。
あとスマホでも一応動くとは思いますが、アニメーションはあまりスムーズじゃないかも(性能によるか)。
初期画面は非常にシンプル。
「オンラインバトル」を押してもらい、次に「クリエイトルーム」を押すことによって対戦相手を待っている状態になります。
企画当初は「オフラインモードつけて、あとオプション画面なんかも用意して...」と
初期表示画面から色々選択できるようにしようと思っていましたが、諦めました。作業多すぎ。
ちなみにログイン機能つけましたが、ログインしなくてもゲームできるようにしてます。
ここに誰か入ってきたら対戦が始まります。
「一緒にやるやついねえよ」って方はchromeだったら右上の三点リーダーのシークレットウィンドウからもう一つウィンドウを開いて自分が作った部屋に参加することで挙動を確かめることが可能です。(名前忘れたけど他のブラウザでもあるはず)
戦闘が始まったらまずはキャラクターを配置します。
画面左上の「PUSH」ボタンを押すとキャラクター一覧サイドバーを開くことができます。
そこからキャラクターを配置していきます。
現在選択しているキャラクターには赤いカーソルがつき、
そのキャラクターをもう一度押すことでキャラクターの能力を確認することができます。
画面上部の秒数カウントはキャラクターの配置が終了するまでの時間で、
これが終わると勝手に次の状態に遷移してしまいますので、早くキャラクターを配置します。
ちなみに今は3分しかキャラクターを配置する時間はないため、じっくりキャラクターを確認している時間はありません。
さくっとキャラクターを配置しましょう。(ちなみにキャラクターの能力の確認は後からでも可能)
秒数カウントが終了するか、右上の「デプロイ完了」を押せば配置完了。
相手もキャラクターの配置が終わっていれば次のフェーズへ移行します。
ようやく戦闘開始ですね。
ということでキャラクターを動かしていきます。
ここでも画面上部に秒数カウントが存在しますが、これもキャラクター配置時と同じです。
カウントの終了と共に自分のターンが終わり、相手のターンとなってしまいます。
また、秒数カウントの横に自分のターンか相手のターンか確認するためのパーツを設置しています。
自分のターンじゃないとキャラクターを動かせないので要注意。
ちなみに、現在は1ターン45秒、キャラクターはmax25体設置できます。
なので基本的に全キャラクターを動かすための時間は足りません。笑
だいたいキャラクターを全部動かし切る前に自分のターンが終わっちゃいます。
なぜ45秒なのかというと、「やばい!自分のターンが終わってしまう!急がないと!」とハラハラドキドキするわけですね。
みなさんハラハラドキドキしたいですよね??私はしたいです。
もはや戦略シミュレーションゲームというより、違うジャンルのゲームになってしまってる気がするって?
まあ気にしない気にしない。
そして、敵のところまで来たら敵を攻撃します。
ちなみにキャラクターによっては「スキル」を持っています。例えば...
- 自分が死んだら違うキャラクターを召喚する
- 一定の確率で再攻撃
- 一定の確率で一撃死
- 山や敵キャラクターに関係なく移動できる
などなど。確か6つぐらい作った気がします。
キャラクターのスキルはキャラクターを選択して戦闘モーダルを開き「能力」を選択することで、確認することができます。
こうして様々な強敵を撃破し、敵の城を占拠したら勝利となります。
特に悩んだポイントは?
仕事の合間とかプライベートの時間を利用して多分3ヶ月ぐらいかかりましたが、やっぱり自分の知らない技術使ってなんか作ろうとすると時間がかかる。。。
ということでここからは勝手にゲームを作っていてつまずいたポイントなんかを厳選して振り返ってみたいと思います。
環境
ちなみに開発環境はこんな感じ。
パソコン
- MacBook (Retina, 12-inch, Early 2016)
- 1.1 GHz Intel Core m3
- 8 GB 1867 MHz LPDDR3
- Intel HD Graphics 515 1536 MB
node -v
v8.10.0
cat package.json
{
"name": "spa-simulation-game",
"version": "1.0.0",
"description": "My brilliant Nuxt.js project",
"author": "Kohei Obe",
"private": true,
"scripts": {
"dev": "nuxt-ts",
"build": "nuxt-ts build",
"generate": "nuxt-ts generate",
"start": "nuxt-ts start",
"lint": "eslint --ext .js,.vue --ignore-path .gitignore .",
"deploy": "npm run build && firebase deploy --only hosting"
},
"dependencies": {
"@nuxt/typescript-runtime": "^0.4.0",
"@nuxtjs/dotenv": "^1.4.0",
"@types/uuid": "^7.0.2",
"bootstrap": "^4.1.3",
"bootstrap-vue": "^2.0.0",
"firebase": "^7.12.0",
"firebaseui": "^4.5.0",
"nuxt": "^2.0.0",
"uuid": "^8.1.0",
"vue-class-component": "^7.2.3",
"vue-property-decorator": "^8.4.1",
"vue-svg-loader": "^0.16.0",
"vuex-class": "^0.3.2",
"vuexfire": "^3.2.2"
},
"devDependencies": {
"@nuxt/typescript-build": "^0.6.0",
"@nuxtjs/eslint-config-typescript": "^1.0.0",
"@nuxtjs/eslint-module": "^1.0.0",
"@nuxtjs/stylelint-module": "^3.1.0",
"babel-eslint": "^10.0.1",
"eslint": "^6.1.0",
"eslint-config-prettier": "^6.10.0",
"eslint-plugin-nuxt": ">=0.4.2",
"eslint-plugin-prettier": "^3.1.2",
"node-sass": "^4.13.1",
"prettier": "^1.19.1",
"sass-loader": "^8.0.2",
"stylelint": "^10.1.0"
}
}
他に書いた方がいいこともあるんだろうか よくわからない。
躓きポイント1 ファイヤーエンブレムのフィールドをどうやって再現しよう?
まず一番最初にぶち当たった壁が 「あのファイヤーエンブレムのマス目フィールドをどうやって再現しよう?」 というところ。
そして思いついたのが一つひとつのセルをdivで表現して、そこにキャラクターを設置するという実装方針。
(他に方法あるのだろうか笑)
結果的に下記のような感じで実装しました。
<div v-for="y of 30" :key="y" :class="$style.row">
<div v-for="x of 30" :id="`${y}-${x}`" :key="`${y}-${x}`">
<FieldCell
:cell-type="decideCellType({ x, y })"
:character="getCharacterAtCell({ x, y })"
:is-host-or-guest="isHostOrGuest"
:lat-lng="{ x, y }"
:field="field"
@onClick="onClickCell"
>
</FieldCell>
</div>
</div>
v-forでy軸に30個、x軸に30個、divをレンダリングしています。
最初は「フィールドはやっぱり大きい方がいいに決まってるでしょ!」とか思って、100×100ぐらいの数のdivを作っていました。
ですがそれだと後述する「キャラクターの移動可能マスを」色付けする処理のところでパソコンだとやや、スマホだとかなりもっさりしてしまいました。
キャラクターとか移動させるたびに10,000個の要素を描画するわけですからね。まあ、重くなって当然。
ちなみに私のパソコンとスマホのスペックはこんな感じ
パソコン
- MacBook (Retina, 12-inch, Early 2016)
- 1.1 GHz Intel Core m3
- 8 GB 1867 MHz LPDDR3
- Intel HD Graphics 515 1536 MB
スマホ
- OPPO R15 Neo
- Android バージョン 8.1.0
- RAM 3.00GM
- プロセッサ Qualcomm SDM450 オクタコア
ただ、実際にキャラクター動かしてみたりするとそもそも100×100マスはフィールドとしてでかすぎる!
敵キャラクターにたどり着くまでが大変。ということで、最終的に今の30×30マスに落ち着きました。
ちなみに、背景はどのマス目がどのような地形なのかという情報をobject形式で保有している field
変数を FieldCell
componentにpropで渡し、自分(セル)の位置情報( lat-lng="{x,y}"
)と照らし合わせ、適切な画像を background-image
で表示しています。
躓きポイント2 キャラクターの移動アルゴリズムどうしよう?
次に頭を悩ませたのがファイヤーエンブレムでいう キャラクターをクリックしたら移動できる範囲のマス目を半透明にして表示させる という処理です。
こちらの処理は下記のページを参考に実装させていただきました。
その結果が次のコードです。
import fieldJson from '~/assets/field.json'
/**
* @param latLng クリックされたキャラクターの座標(x, y)
* @param character クリックされたキャラクター
* @param charactersLatLngMap フィールドに存在する全キャラクターの座標オブジェクト
*/
export const fillMovableArea = (
latLng: ILatlng,
character: ICharacter,
charactersLatLngMap: IField
): IMovableArea => {
const { moveDistance, skill } = character
const movableArea: IMovableArea = {}
movableArea[`${latLng.y}_${latLng.x}`] = moveDistance
const field: IField = fieldJson
const mergedField = Object.assign({}, field, charactersLatLngMap)
computeMovableCell(latLng, moveDistance, movableArea, { x: 1, y: }, skill, mergedField)
computeMovableCell(latLng, moveDistance, movableArea,{ x: -1, y: }, skill, mergedField)
computeMovableCell(latLng, moveDistance, movableArea, { x: 0, y: }, skill, mergedField)
computeMovableCell(latLng, moveDistance, movableArea,{ x: 0, y: - }, skill, mergedField)
return movableArea
}
const computeMovableCell = (
latLng: ILatlng,
moveDistance: number,
movableArea: IMovableArea,
direction: ILatlng,
skill: Array<SkillType>,
mergedField: IField
) => {
const updatedLatLng = {
x: latLng.x + direction.x,
y: latLng.y + direction.y
}
let updatedMovepoint = moveDistance
const cell = mergedField[`${updatedLatLng.y}_${updatedLatLng.x}`]
if (cell) {
switch (cell.type) {
case 'mountain':
case 'character':
if (skill.includes('fly')) {
updatedMovepoint = moveDistance - 1
} else {
return
}
break
default:
updatedMovepoint = moveDistance - 1
}
} else {
updatedMovepoint = moveDistance - 1
}
const lastCheckedMovePoint = movableArea[`${updatedLatLng.y}_${updatedLatLng.x}`]
if (lastCheckedMovePoint && lastCheckedMovePoint > updatedMovepoint) return
movableArea[`${updatedLatLng.y}_${updatedLatLng.x}`] = updatedMovepoint
if (updatedMovepoint === 0) return
if (direction.x !== 1)
computeMovableCell(updatedLatLng, updatedMovepoint, movableArea, { x: -1, y: 0 }, skill, mergedField )
if (direction.x !== -1)
computeMovableCell(updatedLatLng, updatedMovepoint, movableArea, { x: 1, y: 0 }, skill, mergedField )
if (direction.y !== 1)
computeMovableCell(updatedLatLng, updatedMovepoint, movableArea, { x: 0, y: -1 }, skill, mergedField )
if (direction.y !== -1)
computeMovableCell(updatedLatLng, updatedMovepoint, movableArea, { x: 0, y: 1 }, skill, mergedField )
}
- fieldJsonには
{'1_1': { type: mountain | forest | castle } }
のように、keyにy座標_x座標、valueに地形のタイプを保持 - charactersLatLngMapには
{ '1_1': { type: character } }
のようにフィールド場に存在するキャラクターの座標一覧をオブジェクト形式で保持 - computeMovableCellではdirectionの方角のマスが移動可能なマスなのかを計算
- マスのタイプが mountain だったり characterだったらそこは通行不可なのでリターン。ただし、スキル
fly
を持っている場合、通行可能。 - 移動可能だった場合、moveDistanceから -1 する
- 現在のマス目がすでに判定済みで、moveDistance -1 よりも高い数字ならそのルートの調査は不要なのでリターン
- オブジェクト
movableArea
に keyに座標、valueに現在のキャラクターのmoveDistance -1 の値を代入 - moveDistanceが0になっていない限り、来た方角には戻らないようにcomputeMovableCellの処理を各方面(3方向)に続ける
ちなみにマップのオブジェクトが 座標(key): {type: 地形タイプ}(value)
と構造が冗長になっているのは、他にも何か情報を付け足すことを考えていたからです(特定のマスで特定のイベントが発生する、的な)。面倒だからやめましたが。
躓きポイント3 サーバーなしでどうやってターン制を再現しよう
ファイヤーエンブレムでは戦闘が始まると、
- キャラクターの配置
- 戦闘開始
- (自分のターンなら)キャラクターを選択
- 移動可能なマスが表示されるので移動
- 戦闘ダイアログが開かれるのでアクション(攻撃、待機、アイテム、会話など)を選択
- それぞれのアクションに合わせたアニメーションが行われる(攻撃ならダメージ計算)
- キャラクターの行動完了
という順番に処理が行われます。
今回のプロジェクトで戦闘ダイアログで選択できるアクションは攻撃と待機のみ。
それ以外の基本的な挙動については一通り再現できたかなと思っています。
ただ、今回作っているのは 対戦型のファイヤーエンブレム 。
もし対戦相手がオフラインになったら「一生こっちにターンが回ってこない!」なんて状況に陥るわけですね。
で、こういう処理って(たぶん普通は)サーバーを通して行うんでしょうけど、今回はサーバーなし。
そもそも違うクライアント同士の時間の同期させる実装したことないし
やばいどうやったらいいか分からん!ということで結構頭を悩ませました。
最終的な着地点としては、例えば戦闘開始前のキャラクター配置フェーズの例で考えると...
- 自分用のタイマーと相手用のタイマーを設定。相手用のタイマーは自分用のタイマーより長め(10秒ぐらい)
- 自分のタイマーが終了したらfirestoreの自分用のターン終了フラグをtrueにする
- 相手のタイマー終了フラグがtrueかどうか判定
- trueなら戦闘を開始させ、相手用のタイマーも破棄
- falseなら相手用のタイマーが終了するまで待機。相手用のタイマー終了までに相手のターン終了フラグがtrueにならなかったら相手はオフラインとみなし、自分の勝利とする
というような挙動となりました。実際のコードを一部抜粋したのが下記になります。
<script lang="ts">
const NEARLY_TIME_OUT = 170
// 190秒以上経過しても相手からレスポンスがない場合、無条件で勝利とする
const OPPONENT_OFFLINE_TIME = 190
@Component()
export default class DeployHeader extends Vue {
@Prop({ default: undefined })
user!: IUser
@Prop({ default: false })
isDeployModeEnd!: boolean
@Prop({ default: undefined })
battleRoom!: IBattleRoomRes
@Prop({ default: '' })
isHostOrGuest!: 'host' | 'guest'
@Prop({ default: Function })
setBattleRoomWinner!: (battleRoomInfo: {
id: string
winnerUid: string
message?: string
}) => void
@Prop({ default: Function })
setBattleStartAt!: (battleInfo: {
id: string
hostOrGuest: 'host' | 'guest'
}) => void
private lastIntervalId: NodeJS.Timeout | undefined = undefined
public timer: number = 0
public isNearlyTimeOut: boolean = false
public TIME_LIMIT = 180
created() {
const battleStartAt = this.battleRoom[this.isHostOrGuest].battleStartAt
if (!battleStartAt) {
this.setBattleStartAt({
id: this.battleRoom.id,
hostOrGuest: this.isHostOrGuest
})
} else {
this.timer = Math.round(
(new Date().getTime() - battleStartAt.toDate().getTime()) / 1000
)
}
}
mounted() {
this.setMyTimer()
}
destroyed() {
if (this.lastIntervalId) {
clearInterval(this.lastIntervalId)
}
}
setMyTimer() {
this.lastIntervalId = setInterval(() => {
if (this.timer > OPPONENT_OFFLINE_TIME) {
this.setBattleRoomWinner({
id: this.battleRoom.id,
winnerUid: this.user.uid,
message: '対戦相手がオフラインのため勝利しました'
})
} else if (this.timer >= this.TIME_LIMIT) {
this.isNearlyTimeOut = false
this.$emit('deployEnd')
} else if (this.timer >= NEARLY_TIME_OUT) {
this.isNearlyTimeOut = true
this.timer += 1
} else {
this.timer += 1
}
}, 1000)
}
}
</script>
自分と相手のターン終了フラグがオンになるとこのコンポーネントは破棄されるようになっているので、タイマーも自動で破棄されます。
ただ、すでにお気付きの方もいるかと思いますが、この実装だと 二人ともオフラインになった場合は戦闘が進まない ことになります。
戦闘終了時にfirestoreのdocument削除処理を行なっています。
なので戦闘が進まなければオンライン対戦のデータがfirestoreに溜まっていく一方なので、これはよくありません。
とはいえこの問題はサーバーの無い今回のアーキテクチャーではどうしようもない部分かと思いますので、
諦めてcloudFunctionを定期的に走らせて作成から一定時間以上経過しているdocumentに関しては削除することで対応しようかと思います。
nuxtとfirestore、サーバーレスでゲーム作ってみた感想
これ以外にも、
- firestoreのデータ構造どうしよう?
- vuexfireが期待通りに動作しない!
- nuxtのvuexよくわからん!(そもそもreactのreduxは使ったことあったけどvuexは使ったこと無かった)
- アニメーション1P側と2P側でどうやって同期させよう?
などなど、他にも様々な躓きポイントがあって、今回紹介しているのはごく一部です。
(記事が長くなるので他のは別記事で書こうと思います)
また、サーバーが無かったら下記のサイトで紹介されているようなチートにも対抗できないので、今回のアーキテクチャがプロダクト用に採用されることもまずないでしょう。
でも、ほぼフロントエンドしかやったことの無い自分がこのゲームを作ったことによる学びはとても大きかったです。
特にいつも普通に存在してくれているサーバー(バックエンド)のありがたみがよくわかりました。
サーバーが無いせいで
- フロントのコードが複雑になる
- クライアントが違う1Pと2Pの状態を同期させるのが大変っていうか無理なところもある
- チート対策できない
などなど...。他にもまだまだあると思います。サーバーありがたや \(^o^)/
終わりに
ここまで長々と読んでいただいてありがとうございました。
繰り返しになりますが初めてのqiita投稿なので、
正直何をどこまで書いたらいろんな方に興味をもってもらえるのかよく分からず、
説明足らずなところもあったと思います。
なので、もし「ここもうちょっと詳しく知りたい」みたいなポイントとかあったら気軽に
コメントで教えてらもらえたら嬉しいです。
でも一ついえるのはやっぱり自分の知らない新しい技術とか知識に触れるのは楽しい!
なのでまた時間を見つけて興味のある技術を使ってみたいですね。
(今度はバックエンド勉強してバックエンドありのサービス作ってみたい)
追伸
こちらのゲーム作成の続きの話がこちら
→ 初めてのクラス設計で自作ゲームのコードをリファクタリングしてみた