Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
288
Help us understand the problem. What is going on with this article?
@yuneco

VueとSVGを使ってシューティングゲーム『ネコ🐱メザシ🐟アタック🌟』を作ったのでソースと解説

More than 1 year has passed since last update.

→2020年版できました。あわせてご参照くださいませ:cat::star2:

絵描きとかUXとかやりつつフロントもやってる「ゆき」です。前回Vue.jsを使ったポートフォリオサイトを作って、VueとSVGだけでぬるぬる動くアニメーションが実現できることがわかりました。

ここまでやったら次はゲームを作りたいと思うのが人の性ですよね。今回は猫の日に合わせて合計20時間くらいで爆速ゲーム開発をやってみました。多分Vueでシューティングゲームを作った変わり者はあんまりいないと思うので、ソースとノウハウを共有します。

作ったもの

https://mezashiattack.firebaseapp.com
nekogif.gif

ソース(Githubリポジトリ)

タップだけで遊べる簡単なシューティングゲームです。ジャンプしながらメザシを発射し、迫ってくる猫に与える(当てる)とポイントになります

今回作ったゲームの技術的なポイントです

  • アニメーションやゲーム用のフレームワークは利用せず、Vue.jsで構築しています
  • 画像は全てSVGにしてアプリのjsに内包しています(Vue.js本体と合わせても100KB未満)
  • iPhone6でもぬるぬる動きます

Vueでゲーム作る意味ってあるの?

大規模なゲーム開発に(現時点では)Vueは向かない

結論から言うと、複雑なアクションゲームをVueで作るのはしんどいです。
大量のVueコンポーネントをリアクティブに更新するのはそれなりにコストがかかることと、現状Vueのコンポーネントはクラスやインターフェイス的な継承による拡張が困難なため、キャラクター等の類似性の高いコンポーネントの設計が煩雑になりやすいです。ただ、どちらも次期V3で展開のありそうな部分なので、今後は変わっていくかもしれません。

でもミニゲームならメリットが勝る(かも)

一方、ミニゲームであればアクションゲームでもVueで作るメリットはあります。

圧倒的な軽さ

image.png
app,jsの22KBの中に全ての画像が入ってます

NOW LOADING...とかいりません。今回Vue本体に加えて当たり判定とサウンド再生用にライブラリを追加していますが、それにさらに画像(SVG)を足しても100KB未満に収まっています。ミニゲーム以外の部分もVueで作っているのであれば、帯域的にはほとんど追加コストなしにゲームを入れることができるわけです。

普段のWeb知識がそのまま使える

Canvas/WebGLを使用するタイプのフレームワークと違っとて、Vueの世界ではキャラクターも背景も全てhtml/cssの世界で組み立てられます。つまり、レスポンシブやRetina対応等、面倒な部分の対応に普段慣れ親しんだテクニックが使えるわけです。ゲーム専門ではないエンジニアやデザイナにとっては覚えることが少ないので何気に重要なポイントです。

宣言的なゲーム開発ができる

通常、アクションゲームをつくる場合、ステージを初期化して・キャラを配置して・フレームごとにアニメーションを描画して...といった一連の処理を流れとしてプログラムにしていくことが多いと思います。一般的に「手続き的」と呼ばれるアプローチですね。

一方、Vueでゲームを作る場合、真逆の「宣言的」アプローチでプログラムをかけます。
例として今回のゲームのメインステージのテンプレートを示します:

GameStage.vue
<template>
  <div class="stage-root">

    <cat v-for="cat in cats" ref="cat" :key="`cat-${cat.id}`"
      :x="cat.pos.x" :y="cat.pos.y" :s="cat.pos.s"
      @hitMezashi="(mezashiComp) => onCatHitMezashi(cat, mezashiComp)"
      @exit="removeCat(cat)"
    ></cat>

    <mezashi v-for="mezashi in mezashis" ref="mezashi" :key="`mezashi-${mezashi.id}`"
      :x="mezashi.pos.x" :y="mezashi.pos.y" :s="mezashi.pos.s"
      @hitCat="(catComp) => onMezashiHitCat(mezashi, catComp)"
    ></mezashi>

    <player ref="player"
      :x="playerPos.x" :y="playerPos.y" :s="playerPos.s"
      @hitCat="onPlayerHitCat"
    ></player>

  </div>
</template>

Vueをちょっと触っとことのある方なら、これを見るだけで

  • ステージにはplayercatmezashiがいる
  • playerは一体だけ。catmezashiは複数個がそれぞれcatsmezashis配列にしたがって配置される
  • playerhitCatcathitMezashiはキャラ同士の衝突時のイベントらしい あたりはざっと読み取れるとおもいます。

もちろんゲームの種類や規模によると思いますが、この見通しの良さは :innocent: Vueたのしい :innocent: ってなるやつです。ちょうたのしい

ポイントの解説

以下、今回のゲーム開発での具体的なポイントをかいつまんで説明します。

SVG画像の作成と読み込み

今回SVGの作成はiPadアプリのVectornatorを使用しました。
iPadで動くillustratorみたいな子です。しかもまさかの無料!神か!

流れとしては、お絵かきソフトのProcreateでラフ作成→VectornatorでトレースしてSVGに出力→illustratorでパーツ分解 ... という感じです。最後のステップはアセット書き出しが便利なのでイラレを使っていますが、Vectornatorでも頑張ってパーツを一つづつ書き出せば同じことができると思います。
oekaki.jpg

これをVue側で読み込みます。SVGの読み込みにはsvg-to-vue-componentを使います。
このモジュールを使うとSVGを読み込んでVueのコンポーネントにするところをビルド時に自動でやってくれます。ビルド段階での変換になるのでvue.config.jsに設定を追加(ファイルがなければ作成)します。あとは↓のような感じでまるでVueのコンポーネントかのようにimportして使えてしまいます。神か。

<template>
  <mezashi-svg></mezashi-svg> <!-- SVG要素としてレンダリングされる -->
</template>
<script>
  import MezashiSvg from '@/assets/Mezashi.svg' // ※拡張子は必須
  export default {
    components: { MezashiSvg }
  }
</script>

今回はこれをさらに前回作ったEContコンテナコンポーネントでラップして位置や角度等をコントロールします。のちのち当たり判定を取るためにも、ここで要素のサイズや中心点を設定しておきます(これだけちょっと面倒)。

Mezashi.vue
<template>
  <e-cont :x="x - 66" :y="y - 16" :w="132" :h="32" :r="r" :s="s" :ox="66" :oy="16">
    <mezashi-svg></mezashi-svg>
  </e-cont>
</template>
<script>
import ECont from '@/components/core/ECont'
import MezashiSvg from '@/assets/Mezashi.svg'
export default {
  name: 'Mazashi',
  components: { ECont, MezashiSvg },
  props: {
    x: { type: [Number, String], default: 0 },
    y: { type: [Number, String], default: 0 },
    r: { type: [Number, String], default: 0 },
    s: { type: [Number, String], default: 1 }
  }
}
</script>

こんな感じでメザシコンポーネントができたら、使う側は1行です。

メザシ利用側.vue
<mezashi x="100" y="200" r="30"></mezashi>

同じ要領でCatPlayerもコンポーネントにします。

Tweenアニメーションの組み立て

これで好きな位置にキャラを配置できるようになりました。次はアニメーションです。

Tweenクラスの実装

前回ポートフォリオサイトを作った時は頑張って全てをCSS Transitionで実装しましたが、今回はもうちょっと楽に表現力の高いアニメーションを作るため、Tweenアニメーションの機能を自作します。
Tweenクラスの実装は/src/core/Tween.jsを参照してください。基本的にコンストラクタで対象オブジェクトを指定して、to(変化後の値, 時間, イージング)を指定するだけです。それ以外の機能や公開メソッドはありません。

Tweenをよしなにやってくれるライブラリは色々あるので、使い慣れたものがあればそれを入れても良いと思います。今回はできるだけ軽く作りたかったことと、CreatejsのTween.jsのようなメソッドチェーンがめんどくさかったのでPromiseを返すTweenクラスを自作しました。

Promiseを返すと言うことはasync/awaitでアニメーションを繋げられるということなので、Createjsのこの処理は...

CreateJSを使った場合
createjs.Tween.get(target)
  .to({ x: 100, y: 100 }, 1000)
  .to({ x: 200, y: 50 }, 500)

こう書けます

今回作ったTweenの場合
const tw = new Tween(target)
await tw.to({ x: 100, y: 100 }, 1000)
await tw.to({ x: 200, y: 50 }, 500)

これを使うと、キーフレームごとに処理を挟んだりループしたりもTweenクラスに特別な機能を実装しなくても普通のJavaScriptでかけるようになります。

// 上下に揺れながら画面の外に出るまで左に移動する
const tw = new Tween(this.$data)
while (this.x > 100) {
  await tw.to({ x: this.x - 100, y: this.y + (Math.random() - 0.5) * 100 }, 1000)
}

当たり判定

Vueでアクションゲームを作ろうと思った場合、おそらく一番最初にぶつかる壁が当たり判定だと思います。ゲーム用のアニメーションライブラリであればこの辺りは標準的に備えてくれていると思うのですが、もちろんVueにはないので自分でなんとかします。

今回作った当たり判定クラスです:/src/core/CollisionDetector.js

当たり判定を取るためにはまず、各要素の正確な座標を取得する必要があります。
通常のHTMLElement.offsetTop等は入れ子になった要素の相対的な座標である上、CSSのtransformプロパティによる移動や回転を考慮してくれません。このようなケースも含めて、実際に要素が画面内のどこにいるのかを取得したい場合はElement.getBoundingClientRect()を使用します。

src/core/CollisionDetector.js#L38
// this._comps配列にセットされたVueコンポーネント全てについて、矩形領域(バウンディングボックス)を取得
const boxes = this._comps.map(c => {
  const el = c.$el
  if (!el) { return null }
  const box = el.getBoundingClientRect()
  return [ box.x, box.y, box.x + box.width, box.y + box.height ]
})

このメソッドはHTMLの構造や画面のスクロール状態を全てすっ飛ばして、純粋に要素の外周矩形が画面(Viewport)内のどこにいるのかを返してくれるメソッドです。あんまりメジャーではない気がするのですが、IEを含めた主要ブラウザで使えるようです

これを使ってタイマーで定期的にPlayer・Cat・Mazashiの位置を取得して、矩形の交差(衝突)をチェックします。今回は高々数十個なので地道に計算しても良いのですが、交差判定にはメジャーな4分木を使ったアルゴリズムがあるので今回は専用のライブラリ box-intersectを使います。

ちなみにこのアルゴリズムについてはJavaScriptで大量のオブジェクトの当たり判定を効率的にとるが非常にわかりやすいです。ちょっと長いけどおすすめ。

src/core/CollisionDetector.js#L46(つづき)
// 矩形領域(バウンディングボックス)の衝突(重なり)を判定
const result = boxIntersect(boxes).map(indexes => {
  // boxIntersectは衝突したもの同士のindexを返すので、該当するVueコンポーネントに置き換える
  const [i1, i2] = indexes
  return [this._comps[i1], this._comps[i2]] 
})

これで衝突しているコンポーネントの組み合わせが全て取得できました。
最後に、前回のあたり判定結果と比較して新たに重なったものだけを取得し、そのコンポーネントがcollideメソッドを持っていた場合に呼び出します。

src/core/CollisionDetector.js#L52(つづき)
const diffedRes = diffNewResults(this._lastResult, result) // 差分比較。実装はこのふアイルの先頭を参照
this._lastResult = result
diffedRes.forEach(pare => {
  const [c1, c2] = pare
  const c1Name = upperFirst(c1.$options._componentTag)
  const c2Name = upperFirst(c2.$options._componentTag)
  if (c1.collide) {
    c1.collide(c2, c2Name, 0)
  }
  if (c2.collide) {
    c2.collide(c1, c1Name, 1)
  }
})

ちなみにcollideメソッドを呼ばれたコンポーネントでは以下のようにして衝突相手のコンポーネント名を含めたイベントを$emit()しています。

Menashi.vue
methods: {
  /* called by CollisionDetector */
  collide (targetComp, name) {
    this.$emit(`hit${name}`, targetComp)
  }
}

これで冒頭の<mezashi @hitCat="...">のイベントハンドラにつながりました。

サウンドの読み込みと再生

次の難関がサウンドの再生です。これも初めてやると色々ハマるところが多いですが、わかってしまえば簡単なので説明します。とりあえず覚えておくべきこととしては以下の三つ。

  • サウンドの再生にはざっくり言ってAudio.play()を使う方法とWebAudioAPIを使う方法がある
  • PCのみ、またはモバイルでも「タップ等のユーザーアクションをトリガーに音を流すだけ」であればどっちでもよい(前者が簡単)
  • モバイルでタップ等をトリガーにしない再生を行うにはWebAudioAPIを使用し、かつ最初の一度だけはタップ等をトリガーとしなくてはならない

今回は猫とメザシが衝突した場合のように、タップ等をトリガーにしない再生を行いたいので、WebAudioAPIを使わなくてはいけません。ですが、これ自分で書くと結構めんどくさいので今回は手抜きしてライブラリに頼ります。audio-playが簡単に使えて非常に良い感じです。またしても神。

/src/assets/playSound.js

/src/assets/playSound.js
import loadSnd from 'audio-loader'
import playSnd from 'audio-play'

const snds = {}
const load = name => {
  loadSnd(`/snd/${name}.mp3`).then(a => { snds[name] = a })
}
load('btn')
load('catch')
load('jump')
load('gameover')
load('shot')

const playSound = name => {
  const audio = snds[name]
  if (!audio) {
    console.warn(`No sound for: ${name}`)
    return
  }
  playSnd(audio)
}

export default playSound

短いので全部転記しました。起動時にloadしておいて、あとは呼ばれた時にplayするだけです。今回のように読み込むリソース数が少ない場合はこれで十分だと思います。

ひとつ注意しないといけないのが、上でも書いた通り「最初の一度はタップ等をトリガーにしないといけない」こと。今回はゲームスタートのボタンタップ時にplaySound('btn')を呼ぶことでこの条件をクリアしています。

Firebaseにデプロイ

今回もせっかくなのでFirebaseを使った何かをやろうと思ったのですが、時間切れだったので単純にHostingだけ使います。

  1. Firebaseコンソールで新規プロジェクトを作成
  2. firebase initでHostingのみ利用する設定でプロジェクトを初期化
  3. FirebaseコンソールからHostingを有効にする
  4. firebase -deploy

書くことないくらい簡単なのでこの節なくてもいいかな、と思ったのですがFirebaseの神がかった簡単さをステマするために残しておきます。神。

ツイート機能とOGP

image.png
ほんとは結果画面を動的OGPにして、スコアを画像に埋め込みたかったのですが時間切れでそこまではできず。。スコアはツイートの本文に入れて、OGPは固定画像とすることでお茶をにごします。ツイート部分は下記をご参照くださいませ

/src/components/ResultStage.vue#L105

性能評価

結論的には爆速です。
NewImg_2.png

アプリが軽いのも大きいですが、FirebaseのHostingが優秀なのも見逃せないポイント。Accessibility がちょっとダメですが、これはゲームという特性上ダブルタップやピンチでのズームをブロックしているためやむを得ないところのようです。

まとめ

:innocent: Vue + SVG + Firebaseはミニゲームの開発スタックとして超あり

288
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
yuneco
ics
インタラクションデザイン専門のプロダクション。最先端のウェブテクノロジーを駆使し、オンスクリーンメディアの表現分野で活動しています。最新のウェブ技術を発信するサイト「ICS MEDIA」を運営。

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
288
Help us understand the problem. What is going on with this article?