こんにちは 今日は2/22の猫の日に合わせて個人開発したゲーム「ネコメザシアタック」の技術的なポイントを解説する記事です。去年のバージョンはこちら
作ったもの
ソース: https://github.com/yuneco/mezashi2
アプリ: https://nekomzs2.web.app/
(PCでも遊べるけどスマホ推薦です)
使っている技術
そろそろリリース見込みのVue3を先取りした構成です
- Vue(Vue2 + CompositionAPI)
- TypeScript
- CSS Transition(ほとんどのアニメーション)
- SVG(画像 + 一部のアニメーション)
- Firebase (Hosting + FireStoreでランキング)
おしながき(この記事の内容)
作ったもの全部を解説していくとキリがないので、主に去年からの差分を中心に面白いポイントだけ説明していきます。
アニメーションのポイント
角丸の地面を歩くアニメーション
星から星に飛び移るアニメーション
CSS Transitionでゲームを作るときの悩みと解決法
アニメーションの途中で現在の位置や角度をどうやって取得するか
新しい技術スタックのポイント
TypeScript + CompositionAPIの採用
それでは行ってみましょう
ポイント1: 角丸の地面を歩くアニメーション
まず今回の目玉である「たまさんが角丸の星の上をぴょんぴょん跳ねたり歩いたりする表現」を作っていきましょう。(※たまさんはこのゲームのメインキャラクターです)
長方形の上を歩く
いきなり角丸は難しそうなので、ひとまず星はただの長方形にしました。こんな感じでパラメーターtamaX
を渡すといい感じにコーナーリングしてくれるようにしてみます。
<TamaSan :tamaX="1.39" />
この計算は単純だけど面倒なので、独立した関数にしておきます。
import Pos from './Pos' // x, y, r(角度)をセットで保持する値クラス
export default {
/**
* 指定したX位置(一周=8)に対応する座標・回転角を求めます
* @param val X位置
* @param gw 地面の幅
* @param gh 地面の高さ
*/
at: (val: number, gw: number, gh: number): Pos => {
const segIndex = Math.floor(val)
const prog = val - segIndex
const turnsR = Math.floor(val / 8) * 360
switch (segIndex % 8) {
case 0:
return new Pos(gw * prog, 0, 0 + turnsR)
case 1:
return new Pos(gw, 0, prog * 90 + turnsR)
case 2:
return new Pos(gw, gh * prog, 90 + turnsR)
case 3:
return new Pos(gw, gh, 90 + prog * 90 + turnsR)
case 4:
return new Pos(gw * (1 - prog), gh, 180 + turnsR)
case 5:
return new Pos(0, gh, 180 + prog * 90 + turnsR)
case 6:
return new Pos(0, gh * (1 - prog), 270 + turnsR)
default:
return new Pos(0, 0, 270 + prog * 90 + turnsR)
}
}
}
これでTamaSan
コンポーネントでは
const tamaPos = computed<Pos>(() => Angle8.at(props.tamaX, ground.w, ground.h))
こんな感じで算出プロパティとして簡単に位置と角度を取得できます。
角丸を歩けるようにする
つづけてこの長方形の角をとって角丸にしていきます。
一見難しそうに思える角丸ですが、実は種明かしをするとすごく簡単。**「たまさんのキャラクター本体を角丸のコーナーサイズと同じだけ宙に浮かせているだけ」**です
角丸に限らず、惑星の公転のような円運動は全て同様の理屈で単純な回転角の変更のみで表現できます。覚えておくといろんなものをくるくる回せて楽しいですよ
ポイント2: 星から星に飛び移るアニメーション
今回のゲームでは角丸の星を一周するごとに次の星に飛び移ってゲームが進んでいきます。
一見するとこれも複雑なアニメーションを計算しているように思えますが、実は簡単なCSS Transitionのみで実現しています。
コードより前に動画を見てみると仕掛けがわかります。
そう、実はたまさんは星から星に飛び移っていたのではなく、**土台の長方形が移動していただけ(たまさんはその場でジャンプしていただけ)**だったのです。
わかってしまえば簡単ですね。
コードで見てみるとこんな感じ:
<!-- たまさんの土台(中にたまさん本体もいる) -->
<TamaHome
:tamaX="tamaHomeState.tamaX / 100"
:groundPos="activePlanet.pos"
:groundSize="activePlanet.size"
:groundRound="activePlanet.round"
/>
<!-- 惑星1 -->
<Planet
:round="planet1State.round"
:pos="planet1State.pos"
:size="planet1State.size"
/>
<!-- 惑星2 -->
<Planet
:round="planet2State.round"
:pos="planet2State.pos"
:size="planet2State.size"
/>
2つの惑星とたまさん(の土台。中にたまさん本体も入ってる)が並列に並んでいます。
たまさんの土台は、activePlanet
(後述)の位置・角度・サイズに合わせていることに注目してください。
スクリプト部分も見てみます:
setup () {
/** たまさん(土台)の状態 */
const tamaHomeState = reactive<TamaHomeState>({
tamaX: 0,
planetIndex: 0 // 乗っている惑星のindex
})
const planet1State = /* 略:惑星1の位置・角度・サイズ */
const planet1State = /* 略:惑星2の位置・角度・サイズ */
/** planetIndexの値によって惑星1か惑星2のどちらかを返す */
const activePlanet = computed<PlanetState>(() =>
tamaHomeState.planetIndex % 2 === 0 ? planet1State : planet2State
)
// ...略
}
たまさんの土台の位置を決めるactivePlanet
はcomputedを使って惑星1か惑星2のどちらかを返すようにしています。ボタンを押すたびにこのactivePlanetが切り替わることで、隣の星に飛び移る(かのような)アニメーションを表現することができるのです。
おまけ:パフォーマンス戦略
先ほどの動画の右側にVueのプロパティを表示してみました。
位置やサイズの値が書き変わるのはボタンをクリックした瞬間の一度だけなのがわかるかと思います。Tweenライブラリを使ったり、一コマごとに座標を計算してプロパティを変更すればより柔軟なアニメーションを作れますが、その分パフォーマンスは大きく低下します。CSS Transitionで実現できる部分はできるだけまかせて、JavaScript側の処理を減らしてあげると滑らかなアニメーションを実現できます。1
ポイント3:アニメーションの途中で現在の位置や角度をどうやって取得するか
上記したように、アニメーションをTweenやコマ計算ではなく、できるだけCSS Transitionに任せていくのがパフォーマンス向上の重要な戦略です。その一方で途中のアニメーションを全てCSSに任せてしまうとゲームとしては困ったこと もでてきます。
今回の場合、
「タップした瞬間にカツオをタップした方に向け、メザシを発射する」
という部分。
これを実現するにはアニメーションの途中であっても、タップしたその瞬間の位置・角度を取得する必要があります。
しかし残念なことに、CSS Transitionで変化している途中のプロパティを直接取得する方法がありません。 0.5秒後にDOMから直接style.transform
を取得してもトランジション終了後の値であるtransitionX(500px) rotate(30deg)
しか取得できないのです。
任意の時点の位置を取得する
まずは位置からです。
位置の取得は実は去年、当たり判定の処理を作る中でもやっています。具体的にはElement.getBoundingClientRect()を使ってピューポート上での位置を求めればOK。
const getTamaPos = (): Pos | null => {
// テンプレート内のDOMを取得
// ※Vue2のthis.$refs('tamaBody')と同じ
// この部分は後ろの節でも解説しています
const tamaBody = tamaBody.value // div要素
if (!tamaBody) { return null }
const p = tamaBody.getBoundingClientRect()
return new Pos(p.x + p.width / 2, p.y + p.height / 2, 0)
}
クリックされた時点でたまさんの本体が入っている要素の表示領域(BoundingClientRect)を取得し、その中心を現在の位置として返しています。
角度も取得する
先ほどのgetTamaPos
メソットでは角度を0で返してしまっていましたが、カツオをタップした方に向けるためには、今たまさんがどっちを向いているのかを知る必要があります。角度も取得するようにしましょう。
あいにく、Element.getBoundingClientRect
では位置を知ることはできても角度はわかりません。これは、getBoundingClientRectがあくまで画面描画において要素がどこに描画されるかを求める機能しか持たないためです。位置のみを使って角度を求めるため、たまさんのなかに2つの小さなdivを置き、この2つの位置関係から角度を求めることにします。
こんな感じでたまさんの中に2つのDiv要素を配置します
<div class="pos-detector">
<div class="detector-top" ref="detTop"></div>
<div class="detector-bottom" ref="detBottom"></div>
</div>
2つDivを作って
<style lang="scss" scoped>
.pos-detector {
position: absolute;
width: 0px;
height: 100px;
top: calc(50% - 50px);
left: 50%;
div {
position: absolute;
width: 1px;
height: 1px;
}
.detector-top {
top: 0;
}
.detector-bottom {
bottom: 0;
}
}
たまさん中央に縦に並べるだけ。
あとはこの2つのDivの位置から角度を計算します。先ほどのgetTamaPosメソッドに角度を求める処理を追加します。
const detTop = ref<HTMLDivElement>(null) // Vue2の this.$refs.detTop の宣言
const detBottom = ref<HTMLDivElement>(null) // 同上
const getTamaPos = (): Pos | null => {
const elTop = detTop.value
const elBtm = detBottom.value
if (!elTop || !elBtm) { return null }
const pTop = elTop.getBoundingClientRect() // 上側の位置を取得
const pBtm = elBtm.getBoundingClientRect() // 下側の位置を取得
const cx = (pTop.x + pBtm.x) / 2 // 中心X
const cy = (pTop.y + pBtm.y) / 2 // 中心Y
const rad2ang = (rad: number) => rad / Math.PI * 180 // ラジアン→角度の変換関数
const r = rad2ang(Math.atan2((pBtm.y - pTop.y), (pBtm.x - pTop.x))) // Math.atan2で角度を求める
return new Pos(cx, cy, r)
}
「2点の座標がわかれば回転角を簡単に求められる」というのは覚えておいて損のない知識かと思います。CSSアニメーションの文脈で使うことは滅多にないと思いますが、ゲームやビジュアル表現ではよく使う計算です。
ポイント4:TypeScript + CompositionAPIの採用
この節はコードばっかりなので興味ない方は飛ばしつつ見てくださいませ
冒頭でも書いた通り、今回はもうすぐやってくるVue3を見据えて、CompositionAPI + TypeScriptの構成に挑戦しています。CompositionAPI + TypeScriptで何が変わるの?って部分は以前の記事を見てみてください。従来の書き方との対応がわかりやすいかと思います。
Vue.jsレベルを上げよう!○×ゲームを作ってTypeScript&Vue3のCompositionAPIと仲良くなる
ここでは、基本のCompositionAPI + TypeScriptは理解した上で、つまづきポイントと解決策を共有します。
$refs(テンプレートRef)どこいった問題
テンプレートRefは以下のようにしてtemplate
部分で指定した要素や子コンポーネントを参照する機能です。
<template>
<div>
<button @click="getSpan">Get ref</button>
<span ref="msg">Hello</span>
</div>
</template>
<script>
export default {
methods: {
// this.$refsでテンプレート内のSpan要素を取得できる
getSpan () { console.log(this.$refs('msg')) }
}
}
</script>
CompositionAPIではref
を使います。名前は似てるけど使い方はだいぶん違うので注意
<template>
<!-- 同じなので省略 -->
</template>
<script lang="ts">
import { createComponent, ref } from '@vue/composition-api'
export default createComponent({
setup () {
const msg = ref() // 中身のないrefを作る。※重要なのは名前※
// msg.valueでテンプレート内の要素にアクセスできるようになる
const getSpan = () => { console.log(msg.value) }
return {
msg,
getSpan
}
}
})
</script>
紛らわしいのが、このref
は基本的には従来の$data
を代替するものなのに、なぜかテンプレートRefの機能も兼ねているところ。RFCの解説にRefの説明はあるのですが、これを読んでもいまいちテンプレートRefについては理解できないのでは?という気がします....
テンプレートRefの型を決めたい(HTMLElement編)
なんとかテンプレートの要素にアクセスできたところで、次に問題なるのはTypeScriptの型問題です。このままだとmsg.value
のようにして取得した要素をspan
として扱えないのでちょっと嫌ですよね。
前項までのはなしは一応公式にサンプルも書かれているのですが、これ、JSですね...
https://vue-composition-api-rfc.netlify.com/api.html#template-refs
TSでのやり方がなぜか見つからないのですが、一応、下記のようにすれば型を明示することができます。
<script lang="ts">
... 略 ...
const msg = ref<HTMLSpanElement>() // 型を明示してrefを作る
const getSpan = () => {
const msgSpan = msg.value // HTMLSpanElementとして取得できる
}
... 略 ...
</script>
テンプレートRefで子コンポーネントにアクセスしたいんだってば
OK、普通のSpanやDivならなんとかなった。じゃあコンポーネントだと?
↓これでいけそうな気がするじゃないですか?
<template>
<div>
<TamaSan ref="tamaRef" /><!-- このたまさんにアクセスしたい -->
</div>
</template>
<script lang="ts">
import { createComponent, ref } from '@vue/composition-api'
import TamaSan from './TamaSan.vue' // たまさんコンポーネント読み込み
export default createComponent({
components: { TamaSan },
setup () {
const tamaRef = ref<TamaSan>() // TamaSan型
return { tamaRef }
}
})
</script>
怒られます 。「TamaSanは型ではなく値なので、型を指定しろ」とのお言葉。以下のようにするとうまくいきます:
const tamaRef = ref<InstanceType<typeof TamaSan>>()
TS分かんねってなるやつですね。
TS初心者の私はこれ見つけるまでにStackOverflowを2時間くらいさまよいました。(そしてリンク失念しました...ごめんなさい)
また、コンポーネント固有のデータやメソッドは不要で、単にVueのコンポーネントとして扱いたいだけであれば、以下のようにすることもできます:
const tamaRef = ref<Vue>()
(ちなみにこの書き方だと従来の$refs
や$el
にアクセスすることもできます)
うん、複雑。。
しかもこのあたりの型は別に自動的に判別してくれているわけではなく、あくまでも宣言に従って型を当てはめてくれているにすぎません。ちゃんと宣言すればエディタ上での作業は快適になりますが、宣言を誤ればそのまま実行時エラーなので、あまり安全とは言えない気がします。
このあたりはまだまだVue + TypeScriptの辛いところだなぁ...というのが正直な感想です。。
まとめ
そんなわけで今年も気合いで新ゲームをリリースすることができました
去年一年ことあるごとに**Vueでゲーム作るの楽しいよ!!!**って言い続けてるのですが、イマイチまだ流れが来ていない気がします。
もっとみんなVueで遊ぼう
この記事では駆け足で流してしまった部分も、過去にいくつか解説している記事があるので、よろしければご参照くださいませ:
-
特にスペックの低い旧機種のiPhoneではこの恩恵が大きく出ます。今回のゲームの場合、iPhone6レベルでも一度アニメーションを開始してしまえばコマ落ちをほぼ感じずにプレイすることができます。このあたりは以前の記事will-changeで目指す60fpsのぬるぬるCSSアニメーションをご参照くださいませ。 ↩