phina.jsで音ゲーを作ってみる【後編】

前編から大分時間空いてしまい、今年のアドベントカレンダーのネタにしようかと思い始めてましたが、スクフェスっぽい音ゲーを作る方法、後編です。

**[前編](https://qiita.com/pentamania/items/399d133e5440c9424bde)**  
**[リポジトリ](https://github.com/pentamania/sukuphina)**

後編はphina.jsというよりかはただの音ゲープログラミングの話になってきます。

### 前提：用語について
- タップするところを「（タップ）アイコン」
- タイミングを伝えるあの輪っかのことを「マーカー（もしくはノーツ）」
と呼びます。

### 前提：fps設定について
app生成時、fpsというオプションを渡すことができます。
単にfps(Frame Per Second)というと秒間の描画更新頻度を指す（と思う）のですが、
phina.jsでは描画（draw関数の実行）、更新（update関数の実行）、入力受付とあらゆる頻度がこのfps値に準じる設計となっています。
音ゲーでは入力判定が命ですが、デフォルト設定の３０では1/30秒（≒0.033秒）とちょっと心許ないので60にしておきます。
アニメーションも滑らかになってプレイしやすくなります。

```js
phina.main(function() {
  var app = GameApp({
    assets: ASSETS,
    width: SCREEN_WIDTH,
    height: SCREEN_HEIGHT,
    startLabel: 'title',
    backgroundColor: '#8BF5FF',
    title: 'スクフィナ',
    fps: 60, // ここ
  });

  app.run();
});

```

## アイコンの準備
まずアイコンオブジェクトを格納するコンテナ要素（iconGroup）を用意し、位置を中央やや上に配置します。

```js
var gx = this.gridX;
var gy = this.gridY;

var iconGroup = DisplayElement()
.setPosition(gx.center(), gy.span(5))
.addChildTo(this);
```

アイコンそのものついては基本的にはただのCircleShape派生クラスです。
各々idを持たせ、タップ判定を有効化するためinteractiveをtrueに変えます。

```js
phina.define('UnitIcon', {
  superClass: 'phina.display.CircleShape',

  init: function(id, label) {
    this.superInit({
      radius: MARKER_RADIUS,
      strokeWidth: MARKER_STROKE_WIDTH,
      stroke: "magenta",
      fill: "pink",
    });
    this.setInteractive(true); // <- ここ
    this.id = id;

    // 目印用のラベル（必須ではない）
    label = (label != null) ? label : id+"";
    Label({
      text: label,
      fontSize: 60,
    })
    .addChildTo(this);
  },

  // エフェクト表示
  fireEffect: function() {
    EffectWave().addChildTo(this);
  },

});
```

idに応じて扇状に配置されるよう位置を計算します。
ちょっとややこしいですが三角関数を使ってます。

![sukuphina-sankaku.png](https://qiita-image-store.s3.amazonaws.com/0/81823/cb2589dc-04ae-6159-8d7a-c38d5550b65b.png)

（分かる？:confused:）

またタップ時に判定処理を行うよう、onpointstartに処理を仕込んでおきます。

```js

// config.js
var TRACK_NUM = 9; // アイコンの配置数
var ICON_INTERVAL_DEGREE = 180 / (TRACK_NUM - 1); // アイコン間の角度：今回は22.5°
var UNIT_ARRANGE_RADIUS = SCREEN_WIDTH * 0.41 | 0; // 中心からアイコンまでの距離：スクリーン幅の約4割

/* 中略 */

// mainscene.jsのinit内
for (var i = 0; i < TRACK_NUM; i++) {
  var label = INDEX_TO_KEY_MAP[i].toUpperCase(); // 対応キーを表示
  var rad = (i * ICON_INTERVAL_DEGREE).toRadian(); // 度数をラジアン変換
  var icon = UnitIcon(i, label)
  .setPosition(
    Math.cos(rad) * UNIT_ARRANGE_RADIUS,
    Math.sin(rad) * UNIT_ARRANGE_RADIUS
  )
  .addChildTo(iconGroup);

  // タップ・クリックでmainscene.judgeを発動
  icon.onpointstart = function() {
    self.judge(this); // 自分自身(icon)を渡す
  };
}

```

### マーカーの準備
こちらもグループを用意し、アイコングループとちょうど重なる位置に配置します。

```js
this.markerGroup = DisplayElement()
.setPosition(iconGroup.x, iconGroup.y)
.addChildTo(this);
```

マーカーそのものは前編にもでてきたbeatmap（いわゆる譜面）ファイルを元に生成します。
以下のようにどのアイコンに向かうか(track)、判定タイミング（targetTime, 単位：ms）が書かれています。

```js
{
 "offset": 0,
 "notes": [
  {
   "track": 0,
   "targetTime": 0
  },
  {
   "track": 1,
   "targetTime": 500
  },
  {
   "track": 2,
   "targetTime": 1000
  },
  // 省略...
  ]
}
```

（これを手書きで作るのはかなり大変なので、できたらエディタ的なものも別に用意したほうが良い…）

```js
// mainscene.js
beatmap.notes.forEach(function(note) {
  // マーカーを生成
  TargetMarker(note.targetTime, note.track)
  .addChildTo(self.markerGroup)
})
```

#### マーカー（ノーツ）クラス
これもShapeクラスの拡張です。
必要なときに描画されるようvisibleをfalseにし、scaleは0にしておきます。  
isAwakeは処理のまだ終わっていないマーカーであることを示し、処理が終わったらfalseにすることで判定や描画のループ処理でスルーさせます。
targetTime、trackIdは描画と判定に使います。 

vectorは進む方向です。その計算にはtrackIdを使います。
これまた三角関数を使っており、慣れていないと分かりにくいですが、考え方は先程のアイコン位置計算と大体同じです。

```js
phina.define('TargetMarker', {
  superClass: 'phina.display.CircleShape',

  init: function(targetTime, trackId, type) {
    this.superInit({
      radius: MARKER_RADIUS,
      strokeWidth: MARKER_STROKE_WIDTH,
      stroke: "red",
      fill: false,
    });

    this.visible = false;
    this.scaleX = this.scaleY = 0;
    this.isAwake = true;

    this.targetTime = targetTime;
    this.trackId = trackId;

    // 進行方向を計算
    var radian = (trackId * ICON_INTERVAL_DEGREE).toRadian();
    this.vector = phina.geom.Vector2(
      Math.cos(radian),
      Math.sin(radian)
    );
  },
});
```

### マーカーの描画・更新
マーカー描画更新はMainsceneのupdate関数で行っています。

```js
// config.js
var MARKER_APPEARANCE_DELTA = 1000; // ノーツ出現時間(ms)

/* 中略 */

// mainscene.js update内
var markers = this.markerGroup.children;
markers.forEach(function(m) {
  if (!m.isAwake) return;

  var time = this.gameTime
  var rTime = m.targetTime - time; // 相対時間

  if (rTime < MARKER_APPEARANCE_DELTA) {
    // マーカーの位置比率や縮小率（倍率）を計算する
    // ratioはアイコンに近いほど1.0に近づく
    var ratio = (time - (m.targetTime - MARKER_APPEARANCE_DELTA)) / MARKER_APPEARANCE_DELTA;
    var distance = UNIT_ARRANGE_RADIUS * ratio;

    m.setVisible(true)
    .setPosition(
      m.vector.x * distance,
      m.vector.y * distance
    )
    .setScale(ratio);
  }

  // miss判定
  if (RATING_TABLE["miss"].range < -rTime) {
    this.reaction(m, "miss");
  }
}.bind(this));

```

毎フレーム全てのマーカーオブジェクトをループ走査し、現在時間とターゲット時間（targetTime）の差を計算します。
その差がある一定の範囲内（MARKER_APPEARANCE_DELTA以内）のとき、描画を開始（visibleフラグをtrueに）します。

さらにその差分時間を0～1.0の範囲（変数ratio）に収めています。

このratioは距離・縮小率計算に利用していますが、ratioが1.0になるとちょうど位置・サイズがアイコンと重なるようになってます。
（一番右側に向かうマーカーに注目すると理解しやすいかも。）

### 判定処理
判定処理のjudge関数はアイコンをタップ毎に発火します。
渡されたアイコンのidに対応するノーツを探索し、処理を行います。
（このへんはちょっと大雑把すぎるので要最適化）

判定レンジなどの条件を満たしていたらreaction関数を実行します。
これは判定レートに応じてエフェクトを発火したりスコアを加算したりノーツを不活化したりします。

```js
  // mainscene.js
  judge: function(unitIcon) {
    var time = this.gameTime;

    // 判定可能マーカーを探索
    var markers = this.markerGroup.children;
    markers.some(function(m) {
      if (!m.isAwake || m.trackId !== unitIcon.id) return;

      // マーカーが有効かつtrackIdが一致、かつ判定範囲内
      // 判定が狭い順に判定し、該当したらループ拔ける
      var delta = Math.abs(m.targetTime - time);
      if (delta <= RATING_TABLE["perfect"].range) {
        unitIcon.fireEffect(); // エフェクト表示
        SoundManager.play('ring'); // 音鳴らす
        this.reaction(m, "perfect"); // 判定ラベルを表示したり
        return true;
      }

      /* ...省略 */
    })
  },

  reaction: function(marker, rating) {
    // マーカー不可視化
    marker.isAwake = false;
    marker.visible = false;

    RateLabel({text: rating.toUpperCase()})
    .setPosition(this.gridX.center(), this.gridY.center())
    .addChildTo(this);

    this.totalScore += RATING_TABLE[rating].score;
  },
```

だいぶ長く（投稿期間的にも）なりましたが、音ゲーの仕組み的にはこれでほぼ網羅していると思います。
（ロングノートの実装などはまたややこしいので割愛していますが、気が向いたら補足記事として書くかもしれません。）

phina.jsだとタップ系音ゲーで大事（かつ面倒）なマルチタッチ処理が簡単にかけるのでおすすめです。（ダイマ）
気になる人はチャレンジしてみてください。