Font AwesomeとJavaScriptでシューティングゲーム（その１）

これは [scouty Advent Calendar 2018](https://qiita.com/advent-calendar/2018/scouty) の11日目の記事です。

2018/12/18追記： 続きができました。
[Font AwesomeとJavaScriptでシューティングゲーム（その２）](https://qiita.com/naga3/items/a6e68f85c88b28aa82c8)

# はじめに

![shoot.gif](https://qiita-image-store.s3.amazonaws.com/0/32030/6f9ddabf-4bb2-2911-8c8f-982ede777bca.gif)

[Font Awesome](https://fontawesome.com/)のサイトを眺めてたらゲームで使えそうなキャラクターがいっぱいあるなあと思ったので作ることにしました。

今回は、シューティングゲームを作ります。まずベタ書きで作ってみて、それをクラス化するところまでやります。

面倒だったので最初は慣れているjQueryを使っていますが、後ほどバニラなJavaScriptに書き換えます。

GitHubリポジトリ: https://github.com/naga3/font-awesome-shooting

## アジェンダ

- ベタ書きでとりあえず作る
- クラスを使って整理する
- jQueryの使用をやめる

# ベタ書きで作ってみる

https://naga3.github.io/font-awesome-shooting/shoot1.html

以下にソースコードを示します。ブラウザでHTMLを開くとゲームができます。

```html:shoot1.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <style>
      body {
        margin: 0;
        padding: 0;
      }
      #app {
        width: 800px;
        height: 400px;
        background: black;
        position: relative;
        overflow: hidden;
        left: 0;
        top: 0;
        cursor: none;
      }
      #player {
        position: absolute;
        color: lightskyblue;
      }
      #bullet {
        position: absolute;
        color: gold;
        left: 800px;
      }
      #enemy {
        position: absolute;
        color: coral;
        left: -100px;
      }
    </style>
    <title>Font Awesome shooting game!</title>
  </head>
  <body>
    <div id="app">
        <i id="bullet" class="fas fa-toggle-on"></i>
        <i id="player" class="fas fa-fighter-jet fa-3x"></i>
        <i id="enemy" class="fas fa-helicopter fa-flip-horizontal fa-3x"></i>
    </div>
    <script src="https://use.fontawesome.com/releases/v5.5.0/js/all.js"></script>
    <script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
    <script>
      let showBullet = false
      setInterval(() => {
        $('#enemy').css('left', $('#enemy').offset().left - 6)
        if ($('#enemy').offset().left < -100) {
          $('#enemy').offset({
            left: 800,
            top: Math.random() * (400 - $('#enemy').height())
          })
        }
        if (showBullet) {
          $('#bullet').css('left', $('#bullet').offset().left + 12)
          if ($('#bullet').offset().left > 800) showBullet = false
          if (
            $('#bullet').offset().left + $('#bullet').width() >= $('#enemy').offset().left &&
            $('#bullet').offset().left < $('#enemy').offset().left + $('#enemy').width() &&
            $('#bullet').offset().top + $('#bullet').height() >= $('#enemy').offset().top &&
            $('#bullet').offset().top < $('#enemy').offset().top + $('#enemy').height()
          ) {
            showBullet = false
            $('#bullet').css('left', 800)
            $('#enemy').css('left', -100)
          }
        }
      }, 16)
      $('#app').mousemove(e => {
        $('#player').offset({
          left: e.clientX - $('#player').width() / 2,
          top: e.clientY - $('#player').height() / 2
        })
      })
      $('#app').click(e => {
        if (showBullet) return
        showBullet = true
        $('#bullet').offset({
          left: e.clientX - $('#bullet').width() / 2,
          top: e.clientY - $('#bullet').height() / 2
        })
      })
    </script>
  </body>
</html>
```

## ソースの解説

HTML内ののid:`player`に自機、id:`enemy`に敵、id:`bullet`に弾の要素が入っています。
Font Awesomeではclass名に`fa-3x`を指定すると、フォントの大きさが通常の三倍になります。便利ですね。

id:`app`の要素がゲームフィールドで、横800px × 縦400pxの大きさです。

`setInterval`を使って、16msに一回ゲームが更新されるようにしています。16msというはだいたい1/60秒です。歴史的な事情でこのタイミングで更新されるゲームが多いので、意味はありませんがなんとなくこの値にしています。

あとは、当たり判定が若干分かりにくいと思います。

```javascript
$('#bullet').offset().left + $('#bullet').width() >= $('#enemy').offset().left &&
$('#bullet').offset().left < $('#enemy').offset().left + $('#enemy').width() &&
$('#bullet').offset().top + $('#bullet').height() >= $('#enemy').offset().top &&
$('#bullet').offset().top < $('#enemy').offset().top + $('#enemy').height()
```

この部分です。弾を囲む矩形と敵を囲む矩形の座標をチェックして、矩形同士が少しでも重なっていたら当たっていると見なしています。

# クラスを使ってみる

https://naga3.github.io/font-awesome-shooting/shoot2.html

`shoot1.html`のソースコードは短いのですが、いくつか問題点があります。

- jQueryの機能にベッタリなので、違うライブラリを使うのが大変になる。
- キャラクターをDOM要素のidで管理しているので、キャラクターが増えると管理が大変。
- 当たり判定が増える（自機と敵など）と都度当たり判定のコードを書く必要がある。

などなど。

そこで最近のJavaScript（いわゆるモダンJSというやつですね）で追加されたクラスを使って、`shoot1.html`を書き換えてみましょう。

```html:shoot2.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <style>
      body {
        margin: 0;
        padding: 0;
      }
      #app {
        width: 800px;
        height: 400px;
        background: black;
        position: relative;
        overflow: hidden;
        left: 0;
        top: 0;
        cursor: none;
      }
    </style>
    <title>Font Awesome shooting game! - with class</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="https://use.fontawesome.com/releases/v5.5.0/js/all.js"></script>
    <script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
    <script>

      // マウスイベントクラス
      class Mouse {
        constructor() {
          this.click = false
          $('#app').mousemove(e => {
            this.x = e.clientX
            this.y = e.clientY
          })
          $('#app').mousedown(() => this.click = true)
          $('#app').mouseup(() => this.click = false)
        }
      }

      // キャラクタークラス
      let character_no = 1
      class Character {
        constructor(class_name, color) {
          this.id_name = `character${character_no}`
          this.is_show = true
          character_no++
          $('#app').append(`<i id="${this.id_name}" class="fas ${class_name}"></i>`)
          this.$.css({
            position: 'absolute',
            color: color
          })
        }
        get $() { return $('#' + this.id_name) }
        get x() { return this.$.offset().left }
        get y() { return this.$.offset().top }
        get width() { return this.$.width() }
        get height() { return this.$.height() }
        set x(x) {
          this.$.offset({ left: x, top: this.y })
        }
        set y(y) {
          this.$.offset({ left: this.x, top: y })
        }
        show() {
          this.$.css({visibility: 'visible'})
          this.is_show = true
        }
        hide() {
          this.$.css({visibility: 'hidden'})
          this.is_show = false
        }
        hit(target) {
          if (this.x + this.width >= target.x && this.x < target.x + target.width
            && this.y + this.height >= target.y && this.y < target.y + target.height)
            return true
          return false
        }
      }

      // 弾クラス
      class Bullet extends Character {
        move() {
          if (this.is_show) {
            this.x += 12
            if (this.x > 800) this.hide()
          }
        }
      }

      // 自機クラス
      class Player extends Character {
        constructor(class_name, color) {
          super(class_name, color)
          this.mouse = new Mouse()
          this.bullet = new Bullet('fa-toggle-on', 'gold')
          this.bullet.hide()
        }
        move() {
          this.x = this.mouse.x - this.width / 2
          this.y = this.mouse.y - this.height / 2
          if (this.mouse.click && !this.bullet.is_show) {
            this.bullet.x = this.mouse.x - this.bullet.width / 2
            this.bullet.y = this.mouse.y - this.bullet.height / 2
            this.bullet.show()
            this.mouse.click = false
          }
          this.bullet.move()
        }
      }

      // 敵クラス
      class Enemy extends Character {
        move() {
          this.x -= 6
          if (this.x < -100) {
            this.x = 800
            this.y = Math.random() * (400 - this.height)
          }
        }
      }

      // メインループ
      $(() => {
        const player = new Player('fa-fighter-jet fa-3x', 'lightskyblue')
        const enemy = new Enemy('fa-helicopter fa-flip-horizontal fa-3x', 'coral')
        enemy.x = -100
        setInterval(() => {
          player.move()
          enemy.move()
          if (player.bullet.hit(enemy)) {
            player.bullet.hide()
            enemy.x = -100
          }
        }, 16)
      })
    </script>
  </body>
</html>
```

## ソースの解説

`Character`クラスは自機や敵や弾の共通の性質をまとめたクラスです。DOM要素の生成、CSSの設定、キャラクターの表示、消去、座標の取得と設定、大きさの取得、当たり判定などの機能があります。

`Character`クラスを派生して`Player`, `Bullet`, `Enemy`クラスを定義し、それぞれ独自の性質を追加しています。

`Mouse`クラスにはマウスイベントを集約しています。

`shoot1.html`に比べて、大変すっきりしましたね。また、jQueryに依存している部分が`Mouse`クラスと`Character`クラスしかないので、他のライブラリに簡単に置き換えることができるようになりました。

# jQueryの使用をやめる

https://naga3.github.io/font-awesome-shooting/shoot3.html

jQueryを見ると蕁麻疹が出る方のために、バニラなJavaScriptで書き換えたソースコードを示します。`shoot2.html`でjQuery部分を分離したので楽です。

```html:shoot3.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <style>
      body {
        margin: 0;
        padding: 0;
      }
      #app {
        width: 800px;
        height: 400px;
        background: black;
        position: relative;
        overflow: hidden;
        left: 0;
        top: 0;
        cursor: none;
      }
    </style>
    <title>Font Awesome shooting game! - vanilla JavaScript</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="https://use.fontawesome.com/releases/v5.5.0/js/all.js"></script>
    <script>

      // マウスイベントクラス
      class Mouse {
        constructor() {
          this.click = false
          const app = document.getElementById('app')
          app.addEventListener('mousemove', e => {
            this.x = e.clientX
            this.y = e.clientY
          })
          app.addEventListener('mousedown', () => this.click = true)
          app.addEventListener('mouseup', () => this.click = false)
        }
      }

      // キャラクタークラス
      let character_no = 1
      class Character {
        constructor(class_name, color) {
          this.id_name = `character${character_no}`
          this.is_show = true
          this._x = 0
          this._y = 0
          character_no++
          const app = document.getElementById('app')
          app.insertAdjacentHTML('beforeend', `<i id="${this.id_name}" class="fas ${class_name}"></i>`)
          this.$.style.position = 'absolute'
          this.$.style.color = color
        }
        get $() { return document.getElementById(this.id_name) }
        get x() { return this._x }
        get y() { return this._y }
        get width() { return this.$.clientWidth }
        get height() { return this.$.clientHeight }
        set x(x) {
          this._x = x
          this.$.style.left = x + 'px'
        }
        set y(y) {
          this._y = y
          this.$.style.top = y + 'px'
        }
        show() {
          this.$.style.visibility = 'visible'
          this.is_show = true
        }
        hide() {
          this.$.style.visibility = 'hidden'
          this.is_show = false
        }
        hit(target) {
          if (this.x + this.width >= target.x && this.x < target.x + target.width
            && this.y + this.height >= target.y && this.y < target.y + target.height)
            return true
          return false
        }
      }

      // 弾クラス
      class Bullet extends Character {
        move() {
          if (this.is_show) {
            this.x += 12
            if (this.x > 800) this.hide()
          }
        }
      }

      // 自機クラス
      class Player extends Character {
        constructor(class_name, color) {
          super(class_name, color)
          this.mouse = new Mouse()
          this.bullet = new Bullet('fa-toggle-on', 'gold')
          this.bullet.hide()
        }
        move() {
          this.x = this.mouse.x - this.width / 2
          this.y = this.mouse.y - this.height / 2
          if (this.mouse.click && !this.bullet.is_show) {
            this.bullet.x = this.mouse.x - this.bullet.width / 2
            this.bullet.y = this.mouse.y - this.bullet.height / 2
            this.bullet.show()
            this.mouse.click = false
          }
          this.bullet.move()
        }
      }

      // 敵クラス
      class Enemy extends Character {
        move() {
          this.x -= 6
          if (this.x < -100) {
            this.x = 800
            this.y = Math.random() * (400 - this.height)
          }
        }
      }

      // メインループ
      window.onload = () => {
        const player = new Player('fa-fighter-jet fa-3x', 'lightskyblue')
        const enemy = new Enemy('fa-helicopter fa-flip-horizontal fa-3x', 'coral')
        enemy.x = -100
        setInterval(() => {
          player.move()
          enemy.move()
          if (player.bullet.hit(enemy)) {
            player.bullet.hide()
            enemy.x = -100
          }
        }, 16)
      }
    </script>
  </body>
</html>
```

https://github.com/nefe/You-Dont-Need-jQuery 当たりを参照すれば、簡単に書き換えられるのではないかと思います。

`Mouse`クラスと`Character`クラスと、`$.ready`のみ書き換えています。`$.ready`と`window.load`は厳密に言えば若干違いますが、お気になさらず。

# 次回は？

今回はまだまだゲームになっていませんが、次回に、自機のやられ判定、スコア、弾の連射などを追加する予定です。お楽しみに:man_with_turban:

次回: [Font AwesomeとJavaScriptでシューティングゲーム（その２）](https://qiita.com/naga3/items/a6e68f85c88b28aa82c8)