2/22に個人開発のブラウザゲーム『ネコメザシアタック21』をリリースしました。
特に理由はないのですが、3年前からこの時期には毎年同じテーマでゲームを作ってます。
ぶっちゃけ全然流行らないし、当然収益なんて1円もないのだけど、3年続けると見えてくることもあるので今年も記事書きます。せめて供養がわりにLGTM頂けると幸甚の極みです
作ったものの変遷
まあそんなわけで、まずはこの3年間での進歩をみて欲しい
1年目:その場でジャンプするだけの超シンプルゲーム
プレイURL: https://mezashiattack.firebaseapp.com
ソースコード: https://github.com/yuneco/mezashi
解説記事: VueとSVGを使ってシューティングゲーム『ネコ🐱メザシ🐟アタック🌟』を作ったのでソースと解説
- その場でジャンプして弾(メザシ)を発射するだけの簡単仕様
- Vue2(JavaScript)のオーソドックスな構成
- Vueで
div
要素のCSSを操作してキャラクターを動かす方式 - 今コミットログ見返したら2週間(週末2回)で作ったらしい...まじか...
2年目:曲面(角丸)のステージ・ランキング・TS採用・レスポンシブ
プレイURL: https://nekomzs2.web.app/
ソースコード: https://github.com/yuneco/mezashi2
解説記事: VueとCSSとTypeScriptでシューティングゲーム「ネコメザシアタック2020」を作ったのでソースと解説
- 時代に乗るためにTypeScriptとCompositionAPIを採用(Vue本体はまだv2)
- 前回位置固定だったキャラクター(たまさん)が画面内を動き回る仕様に進化
- Firebaseで雑なランキングシステムを実装
3年目:複数のステージ・グラフィックの進化・マルチデバイス
プレイURL: https://nekomzs21.web.app/
ソースコード: https://github.com/yuneco/mezashi21
解説記事: ここ
- Vue3採用
- グラフィックスを超強化
- 画面描画をCSSからPixiJS(Canvas/WebGL)に変更
- デバイス・画面サイズにかかわらず同じ画面を表示できるようになった(やっと!)
変えたこと変わらないこと
少しずつ採用技術を変えながら毎年新しく作りなおしているので基本的には別物なのですが、それでも「変わらないもの」って結構ある。「変えたもの」と「変えなかったもの」をいくつか紹介します。
3年間で変えたこと: JavaScript → TypeScriptへの変更
なんていうかTypeScriptって面倒そうじゃないですか?
型パズル大好きなつよつよエンジニアの人が使えばよくて、私みたいな弱小が趣味で作るならJSでいいじゃーん...そう思ってたのが最初の年
今だから言う。これ完全に間違ってた
別に型パズルはしなくてもいいし、それこそstrict
なしのゆるゆるでもいいけど、とりあえずTypeScriptは入れとけ。きちんとした設計なしに走り出して、途中できちんとしたリファクタリングなんてしない趣味のゲーム開発なんかなら尚更。
↓の図は1年目(js)と3年目(ts)の当たり判定ロジックの比較。
一見ほとんどおなじだけど、VSCodeでカーソル当てるとわかる。持ってる情報が段違い。
雑に作って雑に直していくためにもTypeScriptは必要。もう戻れない。戻っちゃいけない。
3年間で変えたこと:PixiJS + GSAPの採用
最初に紹介したように、1-2年目はDOMのCSSをVueで操作してキャラを動かしているんだけど、まあ正直言うとしんどいです。
1年目みたいな簡単な動きで5個10個の要素を動かすだけなら全然いいんだけど、入れ子になった何十何百の要素を管理するは結構きつい。色々最適化の工夫はあるけど、それでも動くdivの数が200〜300くらいがパフォーマンス的にも限界になることが多いので、装飾的な要素やパーティクルみたいなエフェクトもあんまり使えません。DOMでゲーム作るのは好きなんだけどね。。
そんなわけで今年は一念発起してDOMを捨て、2Dグラフィックスの王道的なライブラリであるPixiJS + GSAPを採用しました。PixiJSではWebGLを使って高速な描画ができる上、WebGLのフィルターを自作すれば表現の自由度が一気に上がるのも嬉しいポイント。
多分誰も気づいてないから自分で言うけど、上の絵の2枚目の水中面、ちゃんと画面全体に水中っぽいエフェクトかかってるのです。これもWebGLのフィルターのおかげ。
PixiJSでフィルターを自作して使う方法は●●WebGL(PIXI.js + glsl)と物理演算(matter.js)で可愛い絵本風タピオカ作ったので解説●●で解説してるので気になる方はみてみてください(今回物理演算はナシです)。
3年間変えなかったこと:Vueの利用
PixiJS + GSAPにするならVueいらなくね? みたいな意見もあると思うのですが、今年も迷わずVueを使ってます。個人的な主張ですが、ラフに個人開発をするときこそ、最初にVueなりReactなりは導入しておいた方が良いです。
だって、vue create
なりcreate-react-app
なりすれば、それだけで汎用的なweb開発環境にTypeScriptまでついてくるですよ?特別な**思想信条**がなければ使った方が早いです。
あと、最初はゲームのメイン画面だけ考えてても、リリースが近くなると「タイトル画面どうしよう?」「ランキングは?」みたいな悩みが出てきます。その手の付加的な画面をCanvasの中に作っていくのはしんどいので、さくっとVueなりReactなりで作れる準備をしておくのは有益です。
今回も、メインのゲーム画面の周辺に表示されるステータス部や上にオーバレイするランキング等はVueで作成・管理しています。Vue部分とPixi部分を連動させる方法は後ろの方でちょっと書きます。
3年間変えなかったこと:SVGの利用
PixiJSに限らず、Canvas/WebGL系のゲーム作成(特にチュートリアルや入門記事)では、画像のフォーマットとしてPNG画像をみっちり並べたスプライトシートを使うのが一般的です(多分)。たとえば『Pixi.js でゲームを作ってみる vol.1』という記事の後ろの方に実際の作例が載っているのですが、見ての通りまあ結構めんどくさいです(※この解説記事自体はとてもわかりやすくありがたい内容です。念のため)。
でもベクター画像のSVGよりもドット絵の方がまだ簡単なんじゃないの?って思うじゃん?
とりあえず↓を読んで欲しい
- キャラや画面要素のサイズ・デザインが変わるたびに元絵を直す必要がある(=つまり主要なパーツのサイズが最初にちゃんと設計できないと辛い)
- Retina(高解像度ディスプレイ)対応がめんどい。あとでまとめて対応できるだろうって思うとほんと痛い目にあう
- ドット絵のフリー素材を集めてきてサイズやトンマナ合わせるのは至難の技。ベクターならわりとなんとでもなる(加工の可否とかの利用条件は確認してね)
- そもそもいい感じのドット絵を描くのはセンスも技術も超必要。あれはほんと職人芸
...というわけで、「とりあえず適当に動けば見た目は気にしない」ならドット絵PNGでもいいけど、そうでければSVGを使うのが吉です。私は普段アナログタッチの絵を描くのでベクターイラストは得意ではないのだけど、それでもゲームの素材作るならイラレでSVG作る方が100倍楽(もちろん個人の見解ry)
あと、SVGはPNGよりも圧倒的に軽いのです。今回(3年目)はそれなりに見た目にも拘ったけど、それでも実際に転送してる画像データは88ファイルで80KB!
スマホの細い回線でも楽に動く上、ややこしいローディング待ちのロジックを作らなくても雑なロード処理で動いちゃうのも趣味開発にはありがたいポイント
3年間変えなかったこと:当たり判定・効果音再生等の基本ロジック
これは手抜きって言えば手抜きなのですが、結果的に当たり判定とSE再生はほぼ3年間同じものを使い回しています。
当たり判定は結局3年間box-intersectという汎用の衝突検知ライブラリを使って実装しています。
サウンド系はaudio-loaderとaudio-playの組み合わせです。
実装は毎年少しずつ変わってるけど、基本は全部同じなのがわかると思います。
当たり判定もサウンド再生も、よほど全部入りのゲームライブラリを使わない限り自分で組み込む必要があるので、上記に限らず一度何かを使えるようになっておくと、ものづくりのスピードが一気に上がります。ここらへんサクッとできるとつよつよ感がでて良いですね
技術的なポイント解説: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の外側でも普通に使えます。
こんな感じで雑にストアを作って...
export default createStore<State>({
state: {
system: {
initialTapped: false
},
stageSetting: {
width: 0,
height: 0,
...
}
...
}
})
Pixiのスプライトから参照するだけ。もちろんwatchやcomputedも使えます。
// ゲーム状態の監視
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クラス
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で使えることにこの間まで全然気づかなかった
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行くらい書くと**ヤバい空気**が漂ってくるのですが、それがTSを使ったりPixiやGSAPの仕組みに乗っかることで数倍までは無理なく伸ばせている...とも言えそうです(もちろん3年でスキルアップした部分もあると信じたい...)。
まとめ
そんなわけで今年もなんとか2/22にゲームをリリースすることができました。
来年は...あるかなぁ...。。来年のことはわからないけど、同じようなテーマでも何回も作っては崩しを繰り返しているとなかなか面白い結果が見えてくることがわかりました。ゆるゆる継続していけるといいですね
今年も同じこと言うけど、Vueでゲーム作るの面白いよ!