最近の音ゲーはいろいろな工夫が凝らされていて、ノートもアプリごとにいろいろな種類がありますね。類型しながら実装方法を考えていきます。
「ノーツ」と呼ばれることもありますが、このサイトでは「ノート」表記で統一します。
音ゲーの主役、ノート
ノートの種類
ほんとの主役は楽曲とも言えますけど、メインゲーム中ではノートがプレイヤーがコントロール出来る唯一の要素です。パラッパラッパーやbeatmania初代はワンショットノートのみでしたが、今は本当にいろいろなノートがあります。(呼び方はゲームごとに様々だと思いますので便宜上の名前です)
- ワンショットノート
- ロングプレスノート
- 連打ノート
- スライドノート
もっとありそうですけど一般的なのはこの辺でしょうか。アーケードの実機ではモーションキャプチャーで手の動きを判定するものや、スマートフォン専用アプリではジャイロで傾きをあわせてタップするノートなんかもあるみたいですね。
Godotでのノートごとの実装
前回記事でやった、MIDIでゲームのノートデータを作る前提で話を進めます。まだ見てない方は↓こちら
MIDIパーサのおさらい
前述記事でインストールしているMidi File Parserの場合は、MIDIファイル読み込み後にクラス内のトラック配列内のmidi配列にオブジェクトとして全ノートが保持されます。
MidiFileParser
├ header MIDIファイルフォーマット、トラック数
└ tracks
├ [0] 主にメタ情報
├ [1] トラック1の情報
│ ├ events 全イベントの配列(下の全部を含む)
│ ├ midi ノートだけの配列 ←これ
│ ├ meta メタイベントだけの配列
│ └ sysex システムエクスクルーシブだけの配列
├ [2]
. .
. .
. .
ここにはキーのOn/OFF、ピッチベンド、コントロールチェンジ(CC)も含まれています。Midiクラスのイベントはstatusの値でタイプを判定する事ができます。
statusの値
statusの値はMidiFileParser.Midi.Statusで定義されているenumです。実態は以下に列挙する整数の値を取ります。param1、param2はそれぞれ0~127までの整数値です。
enumキー | 値 | param1 | param2 |
NOTE_OFF | 8 | ノート高さ | ベロシティ |
NOTE_ON | 9 | ノート高さ | ベロシティ |
NOTE_AT | 10 | アフタータッチのノート高さ | アフタータッチのベロシティ |
CC | 11 | コントロール番号 | 値 |
PGM_CHANGE | 12 | プログラム番号 | |
CHANNEL_AT | 13 | アフタータッチのチャンネル | アフタータッチのベロシティ |
PITCH_BEND | 14 | ファイン | ノート高さ |
ワンショットノート
今更説明するまでもないと思いますけど、Onのタイミングだけを判定するタイプのノートです。最も初期から使われている基本中の基本なノートですね。statusがNOTE_ONのMidiノートイベントだけを抽出すればいいので簡単ですね。
必要な情報としては
- タイミング
- 音程
この2つがあれば十分です。とりあえずコンソールに出力してみましょう。
extends Node2D
@export_file ("*.mid") var midi_file:String = ""
var parser:MidiFileParser
func _ready() -> void:
parser = MidiFileParser.load_file(midi_file)
var tracks = parser.tracks
print("Tracks:",tracks.size())
for t in tracks:
for e in t.midi:
if(e.status == MidiFileParser.Midi.Status.NOTE_ON):
print("MidiEvent Timing:", e.absolute_ticks)
print("\tnote:", e.param1)
print("\tvelocity:", e.param2)
実行結果はこんな感じに。120BPMで2小節目から4和音を四分音符で鳴らしているデータです。

ここでのTimingの値はトラック開始からのTicks($=四分音符 / 480 $)になっています。
NOTE_ONの場合はMIDIクラスのparam1がノート高さの整数(0~127)、param2が強弱を表すベロシティの整数(0~127)です。
他のパラメータも利用しよう
プログラム的に数値よりも音名やオクターブの方が扱いやすいケースもあるかと思います。複数レーンがあるようなシステムや、複数人で共同プレイを想定するならオクターブで振り分けるデータ設計もアリですよね。
- Midi.note_name (String)
- Midi.key (String)
- Midi.octave (int)
で取得できます。上記コードをすこし修正して、
....
for e in t.midi:
if(e.status == MidiFileParser.Midi.Status.NOTE_ON):
print("MidiEvent Timing:", e.absolute_ticks)
print("\tnote:", e.param1)
print("\tnote_name:", e.note_name)
print("\tkey:", e.key)
print("\toctave:", e.octave)
print("\tvelocity:", e.param2)
コンソールに出力したものがこちら。

全部を取得する必要もありませんので、使うものだけピックアップして配列にして保持しておくとよろしいかと思います。
ノートの種類もオクターブで切り替えていくのがいいと思います。
私はなるべく1つのトラックに情報がまとまっている方が、見やすいし難易度調整もしやすいと思うんですよね。同時押し不可能とかもパッと見てわかりますし。トラックは難易度を分けるのに使えば、1個のMIDIファイルで済むので効率的だと思います。
一般的なDAWだとノートPCでもピアノロール3オクターブくらいは同時に表示出来ると思いますが、まぁこの辺はお好みで。。。
この記事では、ど真ん中の4オクターブを、一番ノート数が多くなるであろうワンショットノートとして扱っていきます。好きなように作ってもらって大丈夫です。
ロングプレスノート
ワンショットと違ってゲームプレイ感にかなり差がでるロングプレスノート。ぜひ取り入れたいところですが、MIDIデータ上はNOTE_ONとNOTE_OFFのMIDIイベントがバラバラに記録されているので、少し加工が必要です。
必要なデータは
- ノート高さ
- 打鍵のタイミング
- 離すタイミング
これらをパッケージ化して1ノートにしたいですよね。
基本的にDAWから書き出したMIDIデータではNOTE_ONとNOTE_OFFが対になっていると思います。「C2のONが来たら次のC2はOFF」みたいな構造が原則です。
が。
キーボードなどから直接MIDI-Inする場合はNOTE_OFFの替わりにベロシティがゼロのNOTE_ONが来ることも多いみたいです。NOTE_OFFもベロシティはゼロになっていることがほとんどだと思いますので、音程とベロシティとでON/OFFを判定してもいいと思います。
連想配列に音名で打鍵タイミングのticksを保持し、同音のベロシティ0のノートが来たらクラスにして配列に保持するようなコードがこちら。
extends Node2D
@export_file ("*.mid") var midi_file:String = ""
var parser:MidiFileParser
class LongNote:
var note:String
var start:int
var end:int
func _init(note:String, start:int, end:int) -> void:
self.note = note
self.start = start
self.end = end
func _to_string() -> String:
return "note: %s\tstart:%d\tend:%d"%[note,start,end]
func _ready() -> void:
parser = MidiFileParser.load_file(midi_file)
var tracks = parser.tracks
var tmp_note_timings:Dictionary
var notes:Array[LongNote]
for t in tracks:
for e in t.midi:
if(e.status == MidiFileParser.Midi.Status.NOTE_ON || e.status == MidiFileParser.Midi.Status.NOTE_OFF):
if(e.param2 > 0):
tmp_note_timings[e.note_name] = e.absolute_ticks
if(e.param2 == 0):
var n = LongNote.new(e.note_name,tmp_note_timings[e.note_name],e.absolute_ticks)
notes.push_back(n)
tmp_note_timings.erase(e.note_name)
for n in notes:
print(n)
LongNoteクラスにはデバッグ用に_to_string関数も付けてます。出力結果はこんな感じです。

連打ノート
そのまんま太鼓の達人の連打音符。バリエーションとして、くす玉とか風船のような連打数に上限があるものもあります。
必要なデータは
- ノート高さ
- NOTE_ONのタイミング
- NOTE_OFFのタイミング
- 必要な連打数
ロングノートとほとんどいっしょですが、必要な連打数が追加されただけです。問題は連打数をMIDIの何から取得するか、ですね。
楽曲制作のついでにノーツデータも作れる方が圧倒的に楽ですよね。DAWでコントロールしやすい値として、NOTE_ONのベロシティを使うのが簡単かつ確実かなと思います。コードはロングノートとほとんど変わらないですけど
extends Node2D
@export_file ("*.mid") var midi_file:String = ""
var parser:MidiFileParser
class RapidNote:
var note:String
var start:int
var end:int
var count:int
func _init(note:String, start:int, end:int, count:int) -> void:
self.note = note
self.start = start
self.end = end
self.count = count
func _to_string() -> String:
return "note: %s\tstart:%d\tend:%d\tcount:%d"%[note,start,end,count]
func _ready() -> void:
parser = MidiFileParser.load_file(midi_file)
var tracks = parser.tracks
var tmp_note_timings:Dictionary
var tmp_note_count:Dictionary
var notes:Array[RapidNote]
for t in tracks:
for e in t.midi:
if(e.status == MidiFileParser.Midi.Status.NOTE_ON || e.status == MidiFileParser.Midi.Status.NOTE_OFF):
if(e.param2 > 0):
tmp_note_timings[e.note_name] = e.absolute_ticks
tmp_note_count[e.note_name] = e.param2
if(e.param2 == 0):
var n = RapidNote.new(e.note_name,tmp_note_timings[e.note_name],e.absolute_ticks, tmp_note_count[e.note_name])
notes.push_back(n)
tmp_note_timings.erase(e.note_name)
tmp_note_count.erase(e.note_name)
for n in notes:
print(n)
メンバ変数と連想配列がもう一個増えたくらいですね。
スライドノートのための準備
アーケードの実機やスマホだとよくある、表示に合わせてタップしたまま動かすスライドノート。このノートで必要なデータは
- (元の)ノート高さ
- 打鍵タイミング
- 離すタイミング
- ノートの変化
- 変化のタイミング
- 変化量
ちょっとややこしくなってきましたね。ノートの変化を配列で保持する必要がありそうです。作る人の好みにもなってきますが、アフタータッチの値をスライド変化に使うのをオススメします。
わざわざピッチベンドやCCではなくアフタータッチを使うのは、1トラックで収めたいからです。
CCやピッチベンドを使う場合の注意点
トラック全体の変化になる
上の表を見てカンのいい人は気づいたかもしれませんが、ベロシティやアフタータッチなどはノートナンバーを含みます。つまり1音ずつの変化なのに対して、CCやピッチベンドなどはノートナンバーが無い=トラック全体の変化になります。
CC、ピッチベンドでスライドノートを同時に複数個作る場合は以下のどちらかの手段になると思います。
- 同時に出るノートの数だけトラックを作る
- 同時に出るノートの数だけCCをわけて使う
のちのち難しい曲を作りたいという時にプログラム側も変更が必要だったり、トラック数が不足するというのは避けたいですよね。
打ち込みの楽さは圧倒的にCCやピッチベンドなんですけどね……。
だいたいのDAWで、MIDI機器の入力をカスタマイズできます。モジュレーションホイールやピッチベンドをアフタータッチにマッピングできれば打ち込みもそれほど苦ではないかと思います。
MIDIデータづくりの注意点
デフォ値は記録されない
MIDIイベントは変化させた時しか記録されません。
MIDIデータを作る際も、打鍵と同じタイミングでデフォルト値を入れておく必要があります。スライドノートの場合は左右に動く前提だと思いますので、0~127のちょうど中間の63でセンターということにしましょうか。
値の変化が細かすぎないか
DAWで録音モードでピッチベンドをゆっくり動かしてみると、コントロールポイントがビッシリ書き込まれます。「滑らかな曲線」を表現するために1ティックずつ値を記録してしまうわけですが、音ゲーのスライドノートのためにここまで細かい値は必要ないと思っています。
ビートのタイミングの値のみを記録して、プログラム側でスムーズに表示させればいいかなと思います。
スライドノートの実装
スライドノート、案外ハードルが高いです。MIDIデータが作れたら実装していきましょう。
MidiFileParserプラグインなんですが、時間が同時のMIDIイベントは、Midi.statusの値が小さい順に処理されていきます。上の表のとおりNOTE_OFFが一番値が小さいんですが、これまでのコードだとNOTE_OFFと同時に別なMIDIイベントがあった場合に取りこぼすことになっちゃいます。
二度手間になりますが、先にアフタータッチの方を取得してからもう一度ループを回してノートを取得するようにしています。
extends Node2D
@export_file ("*.mid") var midi_file:String = ""
var parser:MidiFileParser
class SlideChangeArray:
var note:String
var changes:Dictionary[String,Array]
func get_changes(note:String, from:int, to:int):
if !changes.has(note):
return []
var arr = changes[note]
var ret:Array
for a in arr:
if (a[0]>=from && a[0]<=to):
ret.push_back(a)
return ret
func store_changes(note:String, timing:int, value:int):
if !changes.has(note):
changes[note]=[]
changes[note].push_back([timing,value])
func _to_string() -> String:
var ret:String
for c in changes:
for data in changes[c]:
ret += "note:%s\tticks:%d\tvalue:%d\n"%[c,data[0],data[1]]
return ret
class NoteEventArray:
var notes:Array[Array]
class SlideNote:
var note:String
var start:int
var end:int
var changes:Array
func _init(note:String, start:int, end:int, changes:Array) -> void:
self.note = note
self.start = start
self.end = end
self.changes = changes
func _to_string() -> String:
var change_str:String
for c in changes:
change_str += "\tticks:%d\tvalue:%d\n"%c;
return "note: %s\tstart:%d\tend:%d\nchanges:\n%s"%[note,start,end,change_str]
func _ready() -> void:
parser = MidiFileParser.load_file(midi_file)
var tracks = parser.tracks
var tmp_note_timings:Dictionary
var slide_changes = SlideChangeArray.new()
var notes:Array[SlideNote]
var t = tracks[1]
for e in t.midi:
if(e.octave==3):
if(e.status == MidiFileParser.Midi.Status.NOTE_AT):
slide_changes.store_changes(e.note_name,e.absolute_ticks,e.param2)
for e in t.midi:
if(e.status == MidiFileParser.Midi.Status.NOTE_ON || e.status == MidiFileParser.Midi.Status.NOTE_OFF):
if(e.param2 > 0):
tmp_note_timings[e.note_name] = e.absolute_ticks
if(e.param2 == 0):
var start = tmp_note_timings[e.note_name]
var changes = slide_changes.get_changes(e.note_name,start,e.absolute_ticks)
if !changes.is_empty():
notes.push_back(SlideNote.new(e.note_name,start,e.absolute_ticks,changes))
for n in notes:
print(n)
最初のループで取得しているアフタータッチの値は、どこが開始でどこが終了かこの時点ではわからないため、キー名とticksで検索できるようにしてあります。2回目のループでノートのON/OFFタイミングが確定しますので、そこでスライドのタイミングと値を配列で持たせる形でSlideNoteクラスをインスタンス化しています。
なんかもっといいやり方もありそうですけど、とりあえず動くからいいか……。
まだビジュアルは準備していないので、とりあえずコンソール出力で確認。値は取れているようです。

TODO
軽い気持ちで「音ゲー作ってみようか」と思ってましたけど、楽じゃないですね。。
今回いろんなノートタイプを作ってみましたが、試しながらの制作でしたので、それぞれに読み込んでいました。処理の重複や形式が統一されていない部分もありましたので、次回は1曲分を複数種類のノートで表現できるように、継承をつかって整理していく予定です。
コメントを残す