LoginSignup
267

posted at

updated at

Organization

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

TL; DR

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

成果物

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

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

phavuer.png

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

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

ss1_half.png

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

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

もしゲームに関心を持っていただいた方にはWebサイトを見ていただくとして、この記事において伝えたいのは、上記ゲームが色々なものを描画する、歩き回る、会話する、インターフェイスを操作する、といった、それなりの規模の機能が含まれており、Phavuerを使ってそれを実現できた、ということです。

こっちもGithubでオープンソースで公開中です。

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

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

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

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

日本ではあまりユーザーがいないようですが、Githubスター数3万の人気フレームワークです。
開発がとても活発で、アップデートが非常に早いです。

僕はこのライブラリのファンです。

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>

素の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>側に追いやることができました。

JSと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のプロパティやメソッドへ直接アクセスしたいときに少し面倒です。

メリットが薄いケースがある

例えば、フィールドに置くキャラクターコンポーネントを作る際、View部分が、

<template>
  <Image :x="characterX" :y="characterY" texture="character" />
</template>

だけで終わる可能性があります。

また、フィールドに置かれる要素は、Physicsという物理計算を行うクラスをアタッチする場合があり、その場合、xy座標のオリジナルの値がPhysics側でアップデートされてしまい、propsで用いるxy座標と矛盾してしまいます。

これは、v-modelを用いて同期させることもできると思いますが、今回作ったゲームでは、xy座標のpropsは初期値的役割として割り切りました。

このようなケースにおいては、全てをVueコンポーネントにするこだわりを捨てて、素直にGameObjectを継承したPlayerクラスを作るほうがいい場合もありそうです。

そうすると、前述した2つのデメリットも併せて解消できるかもしれません。

パフォーマンス面で劣る

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

Vueというレイヤーが増える以上、素のPhaser3より高速になることはありません。

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

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

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

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

↑ただし、これにはフォローがあります

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

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

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

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

総評

実際にPhavuerでゲームを作ってみて、素のPhaser3より快適に書けるようになった面が多々ありましたし、無事に完成させることもでき、それは古いスマートフォンでも問題なく動作しました。

一方で、上記に挙げたようなデメリットもありましたので、常に有用とは言えず、僕の結論としては、この仕組みは「(作るゲームにもよるけど)アリ」といったところです。

実際、僕は次のゲームにもPhavuerを用いることに前向きです。

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

「フィールド上でキャラやオブジェクトが動く」が開発の大部分を占める場合、この仕組みが役に立ちづらく、デメリットがメリットを上回る可能性があります。

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

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

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

このライブラリは僕が1人で始めたもので、見ての通り他にコントリビューターもついて居ません。
普通の開発者ならそれなりの規模のプロジェクトにわざわざ採用しないと思います。

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

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

そのため、不足があったり僕が死んだときに、Forkして自分でアップデートしていく前提で使うのはアリかも知れません。

おわりに

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

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

.

.

.

.

追記

ちょっと伸びていたので宣伝:
今回作ったゲームの告知ツイートがあんまり伸びず寂しいことになっているので、いいねやRTくれると喜びます!

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
What you can do with signing up
267