Godotで音ゲーづくり:いろんなノートタイプを作る

最近の音ゲーはいろいろな工夫が凝らされていて、ノートもアプリごとにいろいろな種類がありますね。類型しながら実装方法を考えていきます。

「ノーツ」と呼ばれることもありますが、このサイトでは「ノート」表記で統一します。

音ゲーの主役、ノート

ノートの種類

ほんとの主役は楽曲とも言えますけど、メインゲーム中ではノートがプレイヤーがコントロール出来る唯一の要素です。パラッパラッパーや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キーparam1param2
NOTE_OFF8ノート高さベロシティ
NOTE_ON9ノート高さベロシティ
NOTE_AT10アフタータッチのノート高さアフタータッチのベロシティ
CC11コントロール番号
PGM_CHANGE12プログラム番号
CHANNEL_AT13アフタータッチのチャンネルアフタータッチのベロシティ
PITCH_BEND14ファインノート高さ
※ピッチベンドのparam1・param2は便宜上の表記です。

ワンショットノート

今更説明するまでもないと思いますけど、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曲分を複数種類のノートで表現できるように、継承をつかって整理していく予定です。

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です