8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

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

前編
リポジトリ

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

前提:用語について

  • タップするところを「(タップ)アイコン」
  • タイミングを伝えるあの輪っかのことを「マーカー(もしくはノーツ)」
    と呼びます。

前提:fps設定について

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

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

  app.run();
});

アイコンの準備

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

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

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

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

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

(分かる?:confused:

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


// 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)を渡す
  };
}

マーカーの準備

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

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

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

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

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

// 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を使います。
これまた三角関数を使っており、慣れていないと分かりにくいですが、考え方は先程のアイコン位置計算と大体同じです。

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関数で行っています。

// 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関数を実行します。
これは判定レートに応じてエフェクトを発火したりスコアを加算したりノーツを不活化したりします。

  // 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だとタップ系音ゲーで大事(かつ面倒)なマルチタッチ処理が簡単にかけるのでおすすめです。(ダイマ)
気になる人はチャレンジしてみてください。

8
7
0

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
8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?