LoginSignup
253
157

More than 3 years have passed since last update.

VueとPixi.JSでアクションゲーム『ネコメザシアタック』を開発して3年目なのでソースと解説

Posted at

2/22に個人開発のブラウザゲーム『ネコメザシアタック21』をリリースしました。
特に理由はないのですが、3年前からこの時期には毎年同じテーマでゲームを作ってます。
ぶっちゃけ全然流行らないし、当然収益なんて1円もないのだけど、3年続けると見えてくることもあるので今年も記事書きます。せめて供養がわりにLGTM頂けると幸甚の極みです:pray:

3年間の変遷スクショ

作ったものの変遷

まあそんなわけで、まずはこの3年間での進歩をみて欲しい

1年目:その場でジャンプするだけの超シンプルゲーム

1年目画面
プレイURL: https://mezashiattack.firebaseapp.com
ソースコード: https://github.com/yuneco/mezashi
解説記事: VueとSVGを使ってシューティングゲーム『ネコ🐱メザシ🐟アタック🌟』を作ったのでソースと解説

  • その場でジャンプして弾(メザシ)を発射するだけの簡単仕様
  • Vue2(JavaScript)のオーソドックスな構成
  • Vueでdiv要素のCSSを操作してキャラクターを動かす方式
  • 今コミットログ見返したら2週間(週末2回)で作ったらしい...まじか...

2年目:曲面(角丸)のステージ・ランキング・TS採用・レスポンシブ

2年目画面
プレイURL: https://nekomzs2.web.app/
ソースコード: https://github.com/yuneco/mezashi2
解説記事: VueとCSSとTypeScriptでシューティングゲーム「ネコメザシアタック2020」を作ったのでソースと解説

  • 時代に乗るためにTypeScriptとCompositionAPIを採用(Vue本体はまだv2)
  • 前回位置固定だったキャラクター(たまさん)が画面内を動き回る仕様に進化
  • Firebaseで雑なランキングシステムを実装

3年目:複数のステージ・グラフィックの進化・マルチデバイス

mzs21-2.gif
プレイURL: https://nekomzs21.web.app/
ソースコード: https://github.com/yuneco/mezashi21
解説記事: ここ

  • Vue3採用
  • グラフィックスを超強化
  • 画面描画をCSSからPixiJS(Canvas/WebGL)に変更
  • デバイス・画面サイズにかかわらず同じ画面を表示できるようになった(やっと!)

変えたこと変わらないこと

少しずつ採用技術を変えながら毎年新しく作りなおしているので基本的には別物なのですが、それでも「変わらないもの」って結構ある。「変えたもの」と「変えなかったもの」をいくつか紹介します。

3年間で変えたこと: JavaScript → TypeScriptへの変更

なんていうかTypeScriptって面倒そうじゃないですか?:frowning2:
型パズル大好きなつよつよエンジニアの人が使えばよくて、私みたいな弱小が趣味で作るならJSでいいじゃーん...そう思ってたのが最初の年:kissing_cat:

:bow:今だから言う。これ完全に間違ってた:bow:
別に型パズルはしなくてもいいし、それこそstrictなしのゆるゆるでもいいけど、とりあえずTypeScriptは入れとけ。きちんとした設計なしに走り出して、途中できちんとしたリファクタリングなんてしない趣味のゲーム開発なんかなら尚更。

↓の図は1年目(js)と3年目(ts)の当たり判定ロジックの比較。
ts.gif

一見ほとんどおなじだけど、VSCodeでカーソル当てるとわかる。持ってる情報が段違い。
雑に作って雑に直していくためにもTypeScriptは必要。もう戻れない。戻っちゃいけない。

3年間で変えたこと:PixiJS + GSAPの採用

最初に紹介したように、1-2年目はDOMのCSSをVueで操作してキャラを動かしているんだけど、まあ正直言うとしんどいです。

1年目みたいな簡単な動きで5個10個の要素を動かすだけなら全然いいんだけど、入れ子になった何十何百の要素を管理するは結構きつい。色々最適化の工夫はあるけど、それでも動くdivの数が200〜300くらいがパフォーマンス的にも限界になることが多いので、装飾的な要素やパーティクルみたいなエフェクトもあんまり使えません。DOMでゲーム作るのは好きなんだけどね。。:cry:

そんなわけで今年は一念発起してDOMを捨て、2Dグラフィックスの王道的なライブラリであるPixiJS + GSAPを採用しました。PixiJSではWebGLを使って高速な描画ができる上、WebGLのフィルターを自作すれば表現の自由度が一気に上がるのも嬉しいポイント。

img2.jpg

多分誰も気づいてないから自分で言うけど、上の絵の2枚目の水中面、ちゃんと画面全体に水中っぽいエフェクトかかってるのです。これもWebGLのフィルターのおかげ。

PixiJSでフィルターを自作して使う方法は●●WebGL(PIXI.js + glsl)と物理演算(matter.js)で可愛い絵本風タピオカ作ったので解説●●で解説してるので気になる方はみてみてください(今回物理演算はナシです)。

3年間変えなかったこと:Vueの利用

PixiJS + GSAPにするならVueいらなくね? みたいな意見もあると思うのですが、今年も迷わずVueを使ってます。個人的な主張ですが、ラフに個人開発をするときこそ、最初にVueなりReactなりは導入しておいた方が良いです。

だって、vue createなりcreate-react-appなりすれば、それだけで汎用的なweb開発環境にTypeScriptまでついてくるですよ?特別な:angel_tone3:思想信条:angel:がなければ使った方が早いです。

あと、最初はゲームのメイン画面だけ考えてても、リリースが近くなると「タイトル画面どうしよう?」「ランキングは?」みたいな悩みが出てきます。その手の付加的な画面をCanvasの中に作っていくのはしんどいので、さくっとVueなりReactなりで作れる準備をしておくのは有益です。

今回も、メインのゲーム画面の周辺に表示されるステータス部や上にオーバレイするランキング等はVueで作成・管理しています。Vue部分とPixi部分を連動させる方法は後ろの方でちょっと書きます。

ステータスやメニューはVueのDOMで実装

3年間変えなかったこと:SVGの利用

PixiJSに限らず、Canvas/WebGL系のゲーム作成(特にチュートリアルや入門記事)では、画像のフォーマットとしてPNG画像をみっちり並べたスプライトシートを使うのが一般的です(多分)。たとえば『Pixi.js でゲームを作ってみる vol.1』という記事の後ろの方に実際の作例が載っているのですが、見ての通りまあ結構めんどくさいです(※この解説記事自体はとてもわかりやすくありがたい内容です。念のため)。

でもベクター画像のSVGよりもドット絵の方がまだ簡単なんじゃないの?って思うじゃん?
:no_good: とりあえず↓を読んで欲しい :no_good_tone3:

  • キャラや画面要素のサイズ・デザインが変わるたびに元絵を直す必要がある(=つまり主要なパーツのサイズが最初にちゃんと設計できないと辛い)
  • Retina(高解像度ディスプレイ)対応がめんどい。あとでまとめて対応できるだろうって思うとほんと痛い目にあう
  • ドット絵のフリー素材を集めてきてサイズやトンマナ合わせるのは至難の技。ベクターならわりとなんとでもなる(加工の可否とかの利用条件は確認してね)
  • そもそもいい感じのドット絵を描くのはセンスも技術も超必要。あれはほんと職人芸

...というわけで、「とりあえず適当に動けば見た目は気にしない」ならドット絵PNGでもいいけど、そうでければSVGを使うのが吉です。私は普段アナログタッチの絵を描くのでベクターイラストは得意ではないのだけど、それでもゲームの素材作るならイラレでSVG作る方が100倍楽(もちろん個人の見解ry)

image.png

あと、SVGはPNGよりも圧倒的に軽いのです。今回(3年目)はそれなりに見た目にも拘ったけど、それでも実際に転送してる画像データは88ファイルで80KB!
スマホの細い回線でも楽に動く上、ややこしいローディング待ちのロジックを作らなくても雑なロード処理で動いちゃうのも趣味開発にはありがたいポイント:v:

image.png

3年間変えなかったこと:当たり判定・効果音再生等の基本ロジック

これは手抜きって言えば手抜きなのですが、結果的に当たり判定とSE再生はほぼ3年間同じものを使い回しています。

当たり判定は結局3年間box-intersectという汎用の衝突検知ライブラリを使って実装しています。
- 1年目: CollisionDetector.js
- 2年目: CollisionDetector.ts
- 3年目: CollisionDetector.ts

サウンド系はaudio-loaderaudio-playの組み合わせです。
- 1年目: playSound.js
- 2年目: playSound.ts
- 3年目: playSound.ts

実装は毎年少しずつ変わってるけど、基本は全部同じなのがわかると思います。
当たり判定もサウンド再生も、よほど全部入りのゲームライブラリを使わない限り自分で組み込む必要があるので、上記に限らず一度何かを使えるようになっておくと、ものづくりのスピードが一気に上がります。ここらへんサクッとできるとつよつよ感がでて良いですね:sunglasses:

技術的なポイント解説:VueとPixiJSとGSAPでいい感じにゲームを作る

ここからはいくつか、Vue + PixiJS + GSAPでゲームを作る際のポイントや工夫を載せておきます。

ポイント:PixiJSでSVGをロードする + Retinaに対応する

上の方でSVGを超プッシュしておいてあれなのですが、PixiJSでSVGをテクスチャ画像として読み込むのはちょっと工夫が必要です。
基本的にはSVGであってもPixiのテクスチャーとして使うときにはPNGと同様のラスター画像に変換することになるのですが、そのまま読み込むとRetinaに対応できず画像が滲んで表示されます。。

対応方法はいくつかあるのですが、今回は汎用的に使える方法として、Retina環境ではSVGを一度表示サイズの2倍の<img>要素に表示して、その<img>からPixi.Textureを生成する方法をとっています。
https://github.com/yuneco/mezashi21/blob/master/src/logics/loadImgs.ts#L49

今回はかなり雑な実装しかできていないのですが、きちんと使えば画面サイズに合わせてぴったりのテクスチャーを生成することもできるはずです。

ポイント:PixiJSの世界でVueを使う

去年までVueで作ってたゲームをPixiJSベースにして最初に悩むのが状態の管理方法です。
簡単なチュートリアルだと、全部のソースが1ファイルに収まってていろんな変数がグローバルだったりするのだけど、それって現実的じゃないですよね。
Vueのprops/emitsのような仕組みもないので、ルートのPixiApplicationインスタンスからバケツリレーするのもしんどいです。

実はVueの状態管理で定番のVuexはVueの外側でも普通に使えます。

こんな感じで雑にストアを作って...

/src/store/index.ts

store/index.ts
export default createStore<State>({
  state: {
    system: {
      initialTapped: false
    },
    stageSetting: {
      width: 0,
      height: 0,
      ...
    }
  ...
  }
})

Pixiのスプライトから参照するだけ。もちろんwatchやcomputedも使えます。

/src/sprites/Tama.ts

Tama.ts
// ゲーム状態の監視
watch(
  () => store.state.game.play,
  (newVal, oldVal) => {
    if (newVal === 'over') {
      this.gameOverMotion()
    }
    if (oldVal === 'over') {
      this.stepMotion()
    }
  }
)

まあこれも結局は「ちょっとおしゃれなグローバル変数」に過ぎないんだけど、Vue側と決まったルールで状態の共有ができるのは悪くないです。

ポイント:GSAPのtweenアニメーションをasync/awaitに使う

GSAPといえばWebのTweenアニメーションライブラリとしてはおそらく最強で、まあ多分普通の用途でできないことはそうそうないはず。

なのでそのまま素直に使っても良いのですが、1年目にtweenクラスの実装でやったように、できることならアニメーションはasync/awaitで綺麗に書きたい・・・ということで、ちょっとだけラップしてasync/await中心でアニメーションを組み立てられるようにしています。

たまさんのジャンプモーション
  private async jumpMotion() {
    // 新しいモーションを開始する = 古いモーションはこの時点で中断
    const mo = this.nextMotion('jump')

    // 予備動作
    await all(
      mo.animate(cont, { scaleY: 0.75, angle: 15 }, 0.15, Sine.easeOut), // 本体
      mo.animate(amFr, { angle: -40 }, 0.15), // 腕手前
      mo.animate(amBk, { angle: -30 }, 0.15), // 腕奥
      mo.animate(lgBk, { angle: 0 }, 0.15), // 脚奥
      mo.animate(lgFr, { angle: 0 }, 0.15) // 脚手前
    )
    await all(
      // 本体ジャンプ
      run(async () => {
        await mo.animate(cont, { scaleY: 1.1, y: -1000 }, 1.6, Cubic.easeOut)
        await mo.animate(cont, { scaleY: 1.0, y: 0, angle: 0 }, 2.5, Bounce.easeOut)
      }),
      // 腕振り手前
      run(async () => {
        await mo.animate(amFr, { angle: 50 }, 1.3)
        await mo.animate(amFr, { angle: 0 }, 1.0)
      }),
      // 腕振り奥
      run(async () => {
        await mo.animate(amBk, { angle: 30 }, 1.3)
        await mo.animate(amBk, { angle: 0 }, 1.0)
      }),
      // 足振り
      run(async () => {
        await mo.animate(lgBk, { angle: -30 }, 1.2)
        await mo.animate(lgBk, { angle: 0 }, 0.9)
      })
    )

    store.dispatch('tamaJumpEnd')
    mo.alive && this.defaultMotion()
  }

上の例で「モーション」って呼んでるものの本体がこれ↓
/src/logics/animate.tsのAnimatorクラス

Animatorクラス(抜粋)
export class Animator {

  /**
   * キャンセル可能なアニメーションの管理インスタンスを作成します。
   * @param canceller キャンセルすべきかどうかを返すcomputedプロパティ。一度でもtrueになるとその時点で実行中のアニメーションを中断し、以後のアニメーションを全て無視します。
   */
  constructor(canceller?: ComputedRef<boolean>) { ... }

    /**
   * アニメーションを実行します。すでにキャンセルされている場合には何も起こりません。
   * また、実行を開始した後でキャンセルが成立した場合、アニメーションは途中で打ち切られます。
   * 実行されなかった場合及び、実行が打ち切られた場合にもPromiseはresolveになります(rejectはされません)。
   */
  async animate(...params: Parameters<typeof animate>) { ... }

}

基本的にGSAPのtweenをラップしているだけなんだけど、コンストラクターにVueのcomputedを指定することで、「所定の条件を満たさなくなったらアニメーションを打ち切る」動作ができるようにしています。Vuexストアの状態が変わったらアニメーションを切り替える...みたいなことができるわけです。便利!(自賛)

こんな感じでVueの機能は結構Vueコンポーネントの外側でも便利に使えるものがあるので、活用するとPixiJSだけの開発に比べて辛さが緩和できる...はず。

おまけ:3年間でコード量はどれくらい増えた?

なんか1年目より明らかにハードワークになってる気がするので確認してみた。
↓でざっくり確認。余談だけどclocがnpxで使えることにこの間まで全然気づかなかった:innocent:

npx cloc mezashi/src
npx cloc mezashi2/src
npx cloc mezashi21/src

ファイル数

Language 1年目 2年目 3年目
Vuejs Component 11 21 12
JavaScript 9 0 0
TypeScript 0 22 71
GLSL 0 0 3
合計 20 43 86

ステップ数

Language 1年目 2年目 3年目
Vuejs Component 985 2183 1127
JavaScript 234 0 0
TypeScript 0 442 3704
GLSL 0 0 110
合計 1219 2625 4941

見事に毎年2倍になってますね。。これは単に毎年前回以上のこだわりを入れようとして肥大化しただけって見方もあるにはあるのですが、それ以上に使う技術が変わったことで「ざっくり書けるコードの上限」が上がったと見ることもできそうです。

個人開発を長くやっている方ならなんとなくわかってもらえると思うのですが、同じスキルレベルの人が同じアーキテクチャで無計画にコードを書くと、大体同じくらいのボリュームで破綻が見えてきます。

私の場合、生のJS(バニラJS)だと大体1000-1500行くらい書くと:poop:ヤバい空気:poop:が漂ってくるのですが、それがTSを使ったりPixiやGSAPの仕組みに乗っかることで数倍までは無理なく伸ばせている...とも言えそうです(もちろん3年でスキルアップした部分もあると信じたい...)。

まとめ

そんなわけで今年もなんとか2/22にゲームをリリースすることができました。

来年は...あるかなぁ...。。来年のことはわからないけど、同じようなテーマでも何回も作っては崩しを繰り返しているとなかなか面白い結果が見えてくることがわかりました。ゆるゆる継続していけるといいですね:wink:

今年も同じこと言うけど、Vueでゲーム作るの面白いよ!

253
157
0

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
253
157