LoginSignup
333
263

More than 3 years have passed since last update.

VueとCSSとTypeScriptでシューティングゲーム「ネコメザシアタック2020」を作ったのでソースと解説

Last updated at Posted at 2020-03-01

こんにちは:cat: 今日は2/22の猫の日に合わせて個人開発したゲーム「ネコメザシアタック」の技術的なポイントを解説する記事です。去年のバージョンはこちら

作ったもの

ezgif-6-22637686d4bf.gif

ソース: https://github.com/yuneco/mezashi2
アプリ: https://nekomzs2.web.app/
(PCでも遊べるけどスマホ推薦です)

使っている技術

そろそろリリース見込みのVue3を先取りした構成です

  • Vue(Vue2 + CompositionAPI)
  • TypeScript
  • CSS Transition(ほとんどのアニメーション)
  • SVG(画像 + 一部のアニメーション)
  • Firebase (Hosting + FireStoreでランキング)

おしながき(この記事の内容)

作ったもの全部を解説していくとキリがないので、主に去年からの差分を中心に面白いポイントだけ説明していきます。

アニメーションのポイント
 :cat: 角丸の地面を歩くアニメーション
 :cat: 星から星に飛び移るアニメーション
CSS Transitionでゲームを作るときの悩みと解決法
 :cat: アニメーションの途中で現在の位置や角度をどうやって取得するか
新しい技術スタックのポイント
 :cat: TypeScript + CompositionAPIの採用

それでは行ってみましょう :cat2::dash:

ポイント1: 角丸の地面を歩くアニメーション

まず今回の目玉である「たまさんが角丸の星の上をぴょんぴょん跳ねたり歩いたりする表現」を作っていきましょう。(※たまさんはこのゲームのメインキャラクターです)

長方形の上を歩く

いきなり角丸は難しそうなので、ひとまず星はただの長方形にしました。こんな感じでパラメーターtamaXを渡すといい感じにコーナーリングしてくれるようにしてみます。

たまさんコンポーネント
<TamaSan :tamaX="1.39" /> 

image.png

この計算は単純だけど面倒なので、独立した関数にしておきます。

Angle8.ts
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コンポーネントでは

TamaSan.vue
  const tamaPos = computed<Pos>(() => Angle8.at(props.tamaX, ground.w, ground.h))

こんな感じで算出プロパティとして簡単に位置と角度を取得できます。

角丸を歩けるようにする

つづけてこの長方形の角をとって角丸にしていきます。
一見難しそうに思える角丸ですが、実は種明かしをするとすごく簡単。「たまさんのキャラクター本体を角丸のコーナーサイズと同じだけ宙に浮かせているだけ」です

Pasted Graphic 3.png

角丸に限らず、惑星の公転のような円運動は全て同様の理屈で単純な回転角の変更のみで表現できます。覚えておくといろんなものをくるくる回せて楽しいですよ:relaxed::star2:

ポイント2: 星から星に飛び移るアニメーション

今回のゲームでは角丸の星を一周するごとに次の星に飛び移ってゲームが進んでいきます。
一見するとこれも複雑なアニメーションを計算しているように思えますが、実は簡単なCSS Transitionのみで実現しています。

コードより前に動画を見てみると仕掛けがわかります。

ezgif-6-ba6528371c82.gif

そう、実はたまさんは星から星に飛び移っていたのではなく、土台の長方形が移動していただけ(たまさんはその場でジャンプしていただけ)だったのです。
わかってしまえば簡単ですね。

コードで見てみるとこんな感じ:

テンプレート部分
<!-- たまさんの土台(中にたまさん本体もいる) -->
<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のプロパティを表示してみました。

ezgif-6-83547f826881.gif

位置やサイズの値が書き変わるのはボタンをクリックした瞬間の一度だけなのがわかるかと思います。Tweenライブラリを使ったり、一コマごとに座標を計算してプロパティを変更すればより柔軟なアニメーションを作れますが、その分パフォーマンスは大きく低下します。CSS Transitionで実現できる部分はできるだけまかせて、JavaScript側の処理を減らしてあげると滑らかなアニメーションを実現できます。1

ポイント3:アニメーションの途中で現在の位置や角度をどうやって取得するか

上記したように、アニメーションをTweenやコマ計算ではなく、できるだけCSS Transitionに任せていくのがパフォーマンス向上の重要な戦略です。その一方で途中のアニメーションを全てCSSに任せてしまうとゲームとしては困ったこと:cat::sweat_drops: もでてきます。

今回の場合、

「タップした瞬間にカツオをタップした方に向け、メザシを発射する」
Pasted Graphic 8.png

という部分。
これを実現するにはアニメーションの途中であっても、タップしたその瞬間の位置・角度を取得する必要があります。

image.png

しかし残念なことに、CSS Transitionで変化している途中のプロパティを直接取得する方法がありません:cry:。 0.5秒後にDOMから直接style.transformを取得してもトランジション終了後の値であるtransitionX(500px) rotate(30deg)しか取得できないのです。

任意の時点の位置を取得する

まずは位置からです。
位置の取得は実は去年、当たり判定の処理を作る中でもやっています。具体的にはElement.getBoundingClientRect()を使ってピューポート上での位置を求めればOK。

TamaSan.vue
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要素を配置します

Pasted Graphic 9.png

TamaSan.vue
<div class="pos-detector">
  <div class="detector-top" ref="detTop"></div>
  <div class="detector-bottom" ref="detBottom"></div>
</div>

2つDivを作って

TamaSan.vue
<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メソッドに角度を求める処理を追加します。

TamaSan.vue
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の採用

:angel:この節はコードばっかりなので興味ない方は飛ばしつつ見てくださいませ:angel:

冒頭でも書いた通り、今回はもうすぐやってくるVue3を見据えて、CompositionAPI + TypeScriptの構成に挑戦しています。CompositionAPI + TypeScriptで何が変わるの?って部分は以前の記事を見てみてください。従来の書き方との対応がわかりやすいかと思います。

Vue.jsレベルを上げよう!○×ゲームを作ってTypeScript&Vue3のCompositionAPIと仲良くなる

ここでは、基本のCompositionAPI + TypeScriptは理解した上で、つまづきポイントと解決策を共有します。

$refs(テンプレートRef)どこいった問題

テンプレートRefは以下のようにしてtemplate部分で指定した要素や子コンポーネントを参照する機能です。

Vue2標準のテンプレートRef
<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を使います。名前は似てるけど使い方はだいぶん違うので注意

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でのやり方がなぜか見つからないのですが、一応、下記のようにすれば型を明示することができます。

テンプレートRefの型を明示する
<script lang="ts">
    ...  ...
    const msg = ref<HTMLSpanElement>() // 型を明示してrefを作る
    const getSpan = () => {
      const msgSpan = msg.value // HTMLSpanElementとして取得できる
    }
    ...  ...
</script>

テンプレートRefで子コンポーネントにアクセスしたいんだってば:angry:

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>

怒られます:anger: 。「TamaSanは型ではなく値なので、型を指定しろ」とのお言葉。以下のようにするとうまくいきます:

子コンポーネントにアクセス(うまくいく例1)
const tamaRef = ref<InstanceType<typeof TamaSan>>()

TS分かんね:innocent:ってなるやつですね。
TS初心者の私はこれ見つけるまでにStackOverflowを2時間くらいさまよいました。(そしてリンク失念しました...ごめんなさい:innocent:

また、コンポーネント固有のデータやメソッドは不要で、単にVueのコンポーネントとして扱いたいだけであれば、以下のようにすることもできます:

子コンポーネントにアクセス(うまくいく例2)
const tamaRef = ref<Vue>()

(ちなみにこの書き方だと従来の$refs$elにアクセスすることもできます)

うん、複雑。。
しかもこのあたりの型は別に自動的に判別してくれているわけではなく、あくまでも宣言に従って型を当てはめてくれているにすぎません。ちゃんと宣言すればエディタ上での作業は快適になりますが、宣言を誤ればそのまま実行時エラーなので、あまり安全とは言えない気がします。

このあたりはまだまだVue + TypeScriptの辛いところだなぁ...というのが正直な感想です。。

まとめ

そんなわけで今年も気合いで新ゲームをリリースすることができました:sob:
去年一年ことあるごとにVueでゲーム作るの楽しいよ!!!って言い続けてるのですが、イマイチまだ流れが来ていない気がします。

:relaxed: もっとみんなVueで遊ぼう :relaxed:

この記事では駆け足で流してしまった部分も、過去にいくつか解説している記事があるので、よろしければご参照くださいませ:


  1. 特にスペックの低い旧機種のiPhoneではこの恩恵が大きく出ます。今回のゲームの場合、iPhone6レベルでも一度アニメーションを開始してしまえばコマ落ちをほぼ感じずにプレイすることができます。このあたりは以前の記事will-changeで目指す60fpsのぬるぬるCSSアニメーションをご参照くださいませ。 

333
263
1

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
333
263