368
291

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ゲームエンジンにVueを合体させたらゲーム開発が捗った

Last updated at Posted at 2021-05-27

TL; DR

  • Phaser3というJavaScriptのゲーム開発フレームワークがいい感じ
  • でも描画に関する全てをプログラミングベースで書くのは辛いよ…
  • Vueでラッピングしたら便利に書けるようになったよ

成果物

まずは出来上がったライブラリと、それを使って作ったゲームの紹介です。

ラッパーライブラリ『Phavuer』

phavuer.png

ロゴはテンション上げるために作りました。

Github:

ドキュメント:

ドキュメントページ完成しました!(2024/01/04)

夢探索アドベンチャー『リブラの見た夢』

ss1_half.png

リブラの見た夢
公開日 2021/05/27
配信先 Steam, GooglePlay
価格 無料
プレイ時間 2〜3時間

実は、上記ラッパーライブラリ自体は半年ほど前にできていたのですが、記事にする前に、
実際にそれなりの規模のゲームを作ってみて、「実用的にどうなのよ?」ということを確かめたかったです。

上記ゲームはSteamでの評判もよく、ぜひ遊んでみて欲しいのですが、システム的には「マップを歩きまわる」「会話する」「アイテムを集める」などといったそれなりの規模の機能が備わっております。

今回Phavuerを使ってそれくらいの規模のゲームを作ることできた、という点で、ライブラリとしては重要な実績になったかなと思っています。

ゲームについてもGithubでオープンソースで公開中です。

なぜVueでラッピングしたのか

できるだけ皆さんにも共感を得られるように紹介していきたいと思います。

人気ゲームフレームワーク Phaser3 とは?

Phaser3はJavaScriptベースのゲーム開発用フレームワークです。
これが今回ラッピングされた側となります。ケバブラップでいうケバブです。

Githubスター数3万の人気フレームワークで、僕もこのライブラリのファンです。

開発がとても活発で、アップデートが非常に早いです。

Webベースで2Dのゲームを作りたいなら迷わずおすすめできます。

Vue

そしてご存知Vueです。
今回ラップする側、ケバブラップでいうトルティーヤです。

Phaser3の何が辛いか

Phaser3そのものは紛れもなく素晴らしいフレームワークです。

しかし、僕は1年前、Phaser3を使ってRPGのシステムを丸ごと1つ完成させたのですが、正直それはかなり辛かったです。

何が辛かったのか、例を用いて説明させてください。

まず、イカしたゲームを作る第一歩として、画面に真っ赤な四角形を描画しようと思います。
Phaser3ではこのように実現できます。

// scene.add.rectangle(x, y, width, height, color)
scene.add.rectangle(0, 0, 100, 100, 0xFF0000)

何らおかしくなく、至ってシンプルです。

ではそれを発展させて、四角形の上に文字を乗せたボタン的なパーツを作る場合はどうでしょうか。
四角形は、その中に子要素を入れるような、コンテナ的な使い方はできないので、四角形と文字の他にそれらの親となる専用のコンテナが必要になります。

const container = scene.add.container(0, 0)
const rectangle = scene.add.rectangle(0, 0, 100, 100, 0xFF0000)
const text = scene.add.text(0, 0, 'Button')
container.add([rectangle, text])

こちらもまあ何もおかしくないですね。
きっとPhaser3を知らない人でも各行でやっていることを容易に理解できるはずです。

しかしこれは、規模が大きくなる途端に苦痛な作業となります。

ちょうど、それをイメージしやすい身近な例があります。

さきほどのPhaser3の例は、以下に似ていないでしょうか?

const container = document.createElement('div')
const rectangle = document.createElement('div')
const text = document.createTextNode('Button')
container.appendChild(rectangle)
container.appendChild(text)

もちろんこれが何をしているかはWeb開発者はよくご存知かと思います。

ある日、HTMLというマークアップ言語を取り上げられて、上記のようにWeb APIを直接実行しながら大規模なWebサイトを作り上げなければいけないとしたらどうでしょうか?

そんなのは嫌です。

大変すぎますし、できあがったソースコードの視認性も最悪なものになると思います。

つまり、1つ目の理由は、HTMLにあたるマークアップ言語が欲しかったということです。

<!-- こんなふうに書きたい -->
<Container>
  <Rectangle color="0xFF0000" />
  <Text>Button</Text>
</Container>

ちなみにこれに関しては、Godot EngineやUnityといったツールは、GUIを持つことで解決できていることだと思います。

宣言的に書きたい

さらにもう一つ忘れてはいけないこととして、ゲームとは、ユーザーの操作や時間経過によって、描画されるべき内容がまたたく間に変化することです。

それが増えるほどに、先程の例ような手続き的なプログラミングで実現していくことが辛くなっていきます。

例えば、PlayersetHP()内で、画面のあちこちにあるHPゲージの状態を漏れなくアップデートしたり、もしくは全てのHPゲージがPlayerを監視して自身をアップデートしたりしないといけません。

const hpText = scene.add.text(0, 0, '100/100')
const hpBar = scene.add.rectangle(0, 0, 200, 10, 0xFF0000)

const setHp = (hp) => {
  player.hp = hp
  hpText.value = `${player.hp}/${MAX_HP}`
  hpBar.width = 200 * (player.hp / MAX_HP)
}

Webにおける同様の悩みは、ReactやAngular、そしてVueといったライブラリやフレームワークが解消してくれたはずですが、僕としては、今更、ゲームを作るときだけその便利さを忘れるのは辛いです。

ということで、2つ目の理由は、宣言的に状態を記述できるテンプレートエンジンが欲しかったということです。

<!-- こんなふうに書きたい -->
<HP_Text>{{ player.hp }}/{{ MAX_HP }}</HP_Text>
<HP_Bar :width="200 * (player.hp / MAX_HP)" />

<script>
const setHp = (hp) => {
  player.hp = hp
}
</script>

以上がPhavuerを作った背景です。

素のPhaser3とPhavuerのソースコード比較

比較用のサンプルUIを両方のケースで実装してみました。

プラスマークを押すと四角形が増えるというサンプルです。
この謎の四角形が増えたり減ったりして何が嬉しいのかは僕にも分かりません。

See the Pen Phavuer vs Phaser's plane API by laineus (@laineus) on CodePen.

ちなみにソースコードをちゃんと読む必要はありません。

素のPhaser3

MainScene.js
export default {
  let count = 1
  const parentContainer = scene.add.container(20, 20)
  const title = scene.add.text(0, 0, '1. Phaser (Plane API)', fontStyle({ fontSize: 21 }))
  const counter = scene.add.text(0, 35, '', fontStyle())
  const del = scene.add.text(90, 35, '[DELETE]', fontStyle({ color: '#0AF' })).setInteractive()
  parentContainer.add([title, counter, del])
  const getNewBox = (x, y, addable) => {
    const container = scene.add.container(x, y)
    const rectangle = scene.add.rectangle(0, 0, 100, 100, addable ? 0x333333 : 0x888888).setOrigin(0)
    container.add([rectangle])
    if (addable) {
      const plus = scene.add.text(50, 50, '[+]', fontStyle({ color: '#0AF', fontSize: 18 })).setOrigin(0.5)
      rectangle.setInteractive().on('pointerdown', () => {
        count++
        init()
      })
      container.add([plus])
    }
    return container
  }
  del.on('pointerdown', () => {
    count--
    init()
  })
  const init = () => {
    del.setVisible(count > 0)
    parentContainer.list.filter(v => v.type === 'Container').forEach(v => v.destroy())
    counter.setText(`Count: ${count}`)
    const rectCount = count < 7 ? count + 1 : count
    const boxes = [...Array(rectCount)].map((_, i) => getNewBox(i * 110, 60, i === count && count < 7))
    parentContainer.add(boxes)
  }
  init()
}

count: マウントすべき四角形の数
getNewBox: 四角形を生成する関数
init: count分だけgetNewBoxを実行して必要な四角形を用意する

Phavuer

MainScene.vue
<template>
  <Scene name="MainScene" :autoStart="true">
    <Container :x="20" :y="20">
      <Text text="2. Phavuer" :style="fontStyle({ fontSize: 21 })" />
      <Text :text="'Count: ' + count" :y="35" :style="fontStyle()" />
      <Text text="[DELETE]" :x="100" :y="35" :style="fontStyle({ color: '#5AF' })" @pointerdown="count--" v-if="count > 0" />
      <Box :x="i * 110" :y="60" :addable="i === count" @add="count++" v-for="(_, i) in rectCount" :key="i" />
    </Container>
  </Scene>
</template>

<script>
export default {
  components: { Scene, Container, Text, Box },
  setup () {
    const count = ref(0)
    const rectCount = computed(() => count.value < 7 ? count.value + 1 : count.value)
    return { count, rectCount }
  }
}
</script>
Box.vue
<template>
  <Container :x="x" :y="y">
    <Rectangle :width="100" :height="100" :origin="0" :fillColor="addable ? 0x333333 : 0x888888" @pointerdown="onClickBox" />
    <Text text="[+]" :x="50" :y="50" :origin="0.5" :style="fontStyle({ color: '#5AF', fontSize: 18 })" v-if="addable" />
  </Container>
</template>

<script>
export default {
  components: { Container, Rectangle, Text },
  props: ['x', 'y', 'addable'],
  setup (props, context) {
    const onClickBox = () => {
      if (props.addable) context.emit('add')
    }
    return { onClickBox }
  }
}
</script>

この例で必要なデータは「四角形の数」だけになります。

それ以外のViewロジック(四角形の生成や見た目の設定、それらをシーンやコンテナに追加・削除する処理)は、全て<template>側に追いやることができました。

HTMLは最終的な構造を示すうえで読みやすいことがあらためて実感できます。

ロジックの違い

実は、(もし内容を読んだ人が居たら気づいたかもしれませんが、)上記2つは、同じ機能でもロジックに微妙な違いがあります。

Phaser3の方は、追加や削除ボタンを押した際に全ての四角形を一度破棄して、新しい個数分の四角形を再生成しています。

Phavuerの方は、v-forが行う最適化によって、減った分・増えた分の差分の四角形のみが追加・削除されます。

Phavuerの方がエコな作りであることは言うまでもありません。

Phaser3の実装を何故そのようにしたかというと、面倒かつロジックが今以上に複雑になるためです。

Phavuerが内部でやっていること

実際のコードではなく、簡素化したものですが、大体こんな仕組みになっています。

Vue3のCompositionAPIを主に使っています。

Rectangle.vue
import { inject, watch, onBeforeUnmount } from 'vue'
export default {
  props: ['width', 'height', ..],
  setup (props) {
    // Rectangleを生成して、所属シーンへ追加
    const scene = inject('scene')
    const rectangle = new Phaser.GameObjects.Rectangle(scene, props.x, props.y, props.width, props.height)
    // 所属するコンテナがあればそこに追加
    const container = inject('container')
    if (container) {
      container.add([rectangle])
    }
    // propsの変更をGameObjectへ反映
    const stopWatchWidth = watch(() => props.width, value => {
      rectangle.width = value
    })
    // コンポーネント削除時にwatchの停止やdestroyの実行をする
    onBeforeUnmount(() => {
      stopWatch()
      rectangle.destroy()
    })

    return {
      object: rectangle
    }
  }
}

propsの名前は、Phaser3のプロパティ名をそのまま使っているので、基本的にはVueの知識とPhaser3のドキュメントさえあれば使えるようになっています。

使ってみてわかったメリット

Viewロジックが分離され見やすい

さきほど示した通りです。

UIの実装が爆速

これが最も狙い通りだった点です。
jQueryの時代を過ごした開発者が初めてReactやVueを体験した時の感動そのままです。

状態が頻繁に変わる要素を宣言的に実装できるということは、ゲーム開発の世界でも開発を快適にするということが確かめられました。

シーンやコンテナへの追加・削除が全自動

シーンやコンテナへの追加・削除はPhavuerのレイヤーが担当するので、「まずシーンにコンテナを追加して…」「そのコンテナに画像や文字を追加して…」「不要になったらdestroy()を実行して…」といったこと完全に忘れることができます。

<Container>タグへ入れ子にしたり、v-ifを書くだけでそれらが内部で行われます。

これは予想外に快適でした。

HTML要素をゲームに自然に取り入れることができる

ゲームのUIを実装する際、canvasへの描画にこだわる必要はなく、HTMLとCSSで作ってcanvasの上に重ねてしまうのもアリです。
これはHTML5製のゲームのUIを作る手段としてよく比較されます。

Phavuerはその手段を自然に取ることができると思います。

ちなみに僕は全てをcanvas内に描画したい派ですが。

使ってみてわかったデメリット

ある程度の設計力が必要

モデルの分離やCompositionAPIを使ったストアパターンの設計などに慣れていない場合、使いこなすのは難しそうです。

今回僕が作ったゲームでは、例えば「HP」や「HPを減らす機能」などのデータやデータ操作のメソッドをPlayerコンポーネントに持たせてしまいました。

export default {
  setup () {
    const hp = ref(100)
    const addDamage = damage => {
      hp.value -= damage
    }
    return {
      hp,
      addDamage
    }
  }
}

そうしてしまったことで、敵キャラクターからの攻撃といったコンポーネント外の出来事がトリガーとなったとき、refs.player.addDamage(10)といったように、 外部からVueインスタンスへのアクセスが必要になってしまい、行儀の悪いソースコードになってしまいました。

PlayerクラスとPlayerコンポーネントは別々に作成し、PlayerコンポーネントはPlayerクラスのインスタンスを受け取ってそれを描画することに専念することでキレイに書けると後から気づきました。

const player = new Player()
<Player :player="player" />
player.addDamage(100)

サンプルゲームのソースはそのように実装してみました。

GameObjectまでのアクセスが少し遠くなる

突然知らない単語を出してしまいましたが、GameObjectとはPhaser3の四角形、コンテナ、画像、テキストといったさまざまな要素が継承している基本的なクラスで、例えば「プロパティにx,y座標を持っている」、「destroy()でシーンから削除できる」、などといった機能を持っています。

Phavuerを使わない場合、

class Player extends Image {
  hp = 100
  setHp (value) {
    this.hp = value
  }
}

このようにGameObjectやそれ継承するクラスを継承して、独自のクラスを作るといった使い方できます。

当然ですが、このPlayerインスタンスは直接GameObjectの機能を使うことができます。

Vueコンポーネントで作る場合、VueインスタンスとGameObjectを合体させるのは困難なため、

export default {
  setup () {
    const gameObject = ref(null)
    return {
      gameObject
    }
  }
}

このように、Vueインスタンスの中にGameObjectがあるような形になり、GameObjectのプロパティやメソッドへ直接アクセスする必要がでてきたときに少し面倒です。

パフォーマンス面で劣る

これは実際に使ってから気づいたデメリットではなく、大前提のデメリットとして考えていたことです。

Vueというレイヤーが増える以上、理論上のパフォーマンスは、素のPhaser3より高速になることはありません。

パフォーマンスチューニングをして、ギリギリまでリッチにする前提のゲームなら、このデメリットは受け入れられないと思います。

実際に作ってみてパフォーマンスの差を感じられたかと言うと、「分からない」が正直なところです。
全く同じ内容のゲームを作ってみないと、体感できる差があるかどうかは分からない気がします。

実装の仕方として気をつける必要があったのは、ゲームは60FPSなら1秒間に60回のループが実行されますが、この中でcomputedが依存する変数を書き換えてしまうと、computed全体が再計算されて、本来必要のない計算まで行われて無駄を生むということです。

もちろんWeb開発でも同じように注意すべきことではありますが、ゲームではこれがより実害に繋がりやすいと思われます。

ただし、上記は、カタログスペックの比較と言いますか、「パフォーマンスの限界を目指したとき」に素のPhaser3が勝つという話であって、人間がコードを書く以上、常に限界まで最適化された実装ができるとは限りません。

例えば、ロジック比較の項目で触れた「ロジックの違い」を思い出してください。

Phavuerで簡単に実現できたことが、素のPhaser3だと面倒に感じて怠慢な実装にしてしまいました。

そのように二次的要因によって素のPhaser3の方がパフォーマンスの悪化が起こる機会もあるんじゃないかとも思っています。(ちょっと強引)

総評

実際にPhavuerでゲームを作ってみて、素のPhaser3より快適に書けるようになった面が多々ありましたし、無事に完成させることもできました。

一方で、上記に挙げたようなデメリットがあることには注意です。

僕個人としては、この先のゲーム開発でもう素のPhaser3を使う気はなく、Phavuerをメンテナンスしながら使っていくつもりです。

どんなゲームに向いている?

リバーシやカードといったボードゲーム系、ADVやお店経営ゲームのようなUI操作が多いゲームなどには特に力を発揮すると思います。

フィールド上でキャラやオブジェクトが動くような部分が実装の中心で、UI要素はほぼないようなゲーム、または、パフォーマンスギリギリまで要素やエフェクトを詰め込みたいようなゲームでは、この仕組みが役に立ちづらく、デメリットがメリットを上回る可能性があります。

あるいは、UIだけVueコンポーネント化するなど、ハイブリットに使うのものアリだと思います。

"Phavuer"を使ってゲームを作るべきか?

このライブラリはオープンソースですが、現状は僕が1人でメンテナンスしている状態です。

また、現状のPhavuerは全てのGameObjectをサポートしているわけではありません。

そのため、不足があった場合に、ご自身でソースを読んだり、プルリクやForkで修正していくような必要が出てくる前提で、採用を検討するのがよいかと思います。

ただし、Phavuerのやっていることは、ほんとにPhaser3とVueつないでいるだけで、ソースコードも少ないので読むのも手を加えるのも容易いはずです。

おわりに

いかがだったでしょうか?

この記事が気に入った方は、高評価とチャンネル登録お願いします。


↓↓ ゲームに関する他の記事もよかったらお読みください!

368
291
6

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
368
291

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?