前編から大分時間空いてしまい、今年のアドベントカレンダーのネタにしようかと思い始めてましたが、スクフェスっぽい音ゲーを作る方法、後編です。
後編は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に応じて扇状に配置されるよう位置を計算します。
ちょっとややこしいですが三角関数を使ってます。
(分かる?)
またタップ時に判定処理を行うよう、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だとタップ系音ゲーで大事(かつ面倒)なマルチタッチ処理が簡単にかけるのでおすすめです。(ダイマ)
気になる人はチャレンジしてみてください。