Godotで音ゲーづくり:MIDIデータを扱う

音ゲーの構造の考察とGodotにおける音声周りのあれこれをメモしています。Godot標準機能ではMIDIは取り扱えないんですが、無償アセットライブラリでMIDIのデータを扱うことが出来るようになります。

執筆時点での環境(2025-04-02)

  • Windows11
  • Godot4.4.1-stable
  • MIDI Importer 1.0.1

音ゲーのタイプ

「音ゲー」とひとくくりに言っても、ゲーム自体のプレイアビリティは結構バラバラです。プレイヤーの入力に対して、ゲームからどんな反応を返すかでゲームシステムに求められる機能が変わってきます。

プレイヤーの音が出るかどうか

  • 入力に対して音楽的な音色を鳴らすタイプ
    • 楽曲の一部パートをプレイヤーが演奏するタイプ
      • 例:beatmania
    • 楽曲は全パート鳴っているが、追加のノートを鳴らすタイプ
      • 例:太鼓の達人
  • 入力に対して音色を鳴らさないタイプ
    • エフェクト的な音声が鳴る
      • 例:ビブリボン、リズム天国
    • ビジュアルエフェクトのみ
      • 例:DanceDanceRevolution

ゲームのコンセプトで大きく変わってくるところですね、あまりやってないんですがプロセカやアイマスなど歌ものでは音色がならないイメージがあります。

ノートの種類

また、プレイヤーが叩くノートにも色々種類がありますね。

  • 単純なタップ
    • ワンショットのノート 打楽器系など
    • フリックもこれに含まれます
  • ロングタップ
    • 押しっぱなしのノート
      • 押すタイミング・離すタイミング両方を判定するもの
      • 押すタイミングだけ判定するもの
    • フリックもここに含んでいいような
  • 連打
    • 太鼓の達人のイメージ

楽曲自体が変化するもの

パラッパラッパーのように、スコアによって楽曲自体が変化していくシステムのゲームもありますね。また曲中でBPMが変化するものもあります。

これをどうやって実装していくか、というのがこの記事のテーマです。

音ゲーの音楽システム

音楽は普通のゲームではサブ的な要素なので、一般的なゲームエンジンでは「デフォルト機能で音ゲーがここまで作れます!」とはならないのが現状です。UnityでもGodotでも、自前で設計・実装しなければならない要素は多いです。

どのゲームがどんなシステムで作られているかはわからないので想像になりますが、サウンドシステムについては概ね下記のような構造になっていると思われます。

  • メインの楽曲を用意する
    • ケース1:WAV・Oggなど音声データで再生する
    • ケース2:MIDI + SoundFontで再生する
  • プレイヤー用のノートデータを用意する
    • ケースA:MIDIデータ
    • ケースB:CSV、JSONなどのテキストデータ
  • (音を鳴らす場合)プレイヤーが鳴らすパートの音声を用意する

DAWで楽曲を作成する場合、大抵のソフトでOggで楽曲全体を書き出しして、プレイヤー用のピアノロールも別途MIDIで書き出しが出来ます。音声担当の負担が少ないのはケース1とケースAの組み合わせじゃないでしょうか。

スライド、フリックなど単純なON/OFF以外が求められる操作性のノートを作る場合は、MIDIのピッチベンドやCCイベントなども利用できます。

音楽とゲームの隔たり

音楽の世界では各楽器の発音のタイミングは、楽譜上のBPMと拍子と音符の長さのみで表されます。MIDIデータはまさしくそういうデータ構造です。(〇〇小節目の〇〇tick という表現です)

それに対してゲームエンジン上では楽譜データは通常は扱えませんので、ミリ秒などの実時間に変換する必要があります。

MIDIファイルはバイナリデータです。規格が完全に公開されていますので自前で変換機能を実装するのもそれほどハードルは高くないと思いますけど、既に優秀なパーサーがAssetLibにあるのでそちらを使っていきましょう。

MIDI関連のAssetLib

AssetLibタブで「MIDI」で検索するといくつか出てきます。

MIDI Importerをインストール

GodotはもともとMIDIファイルには対応していませんので、プロジェクトフォルダにMIDIファイルを入れてもGodotエディタのファイルシステムドックには表示されません。それを表示させるプラグインがこちら。

AssetLibからインストールしたら、プロジェクトを再読込しましょう。すると……

こんな感じでMIDIファイルが表示されるようになります。楽ちん。

Midi File Parser / Playerをインストール

再生機能が充実したより高機能なものもあるのですが、今回の作り方だとMIDIノートとイベントのデータが取得できればOK。このスクリプトがいい感じなのですが、

バグがあったのでフォークして修正したものがこちら。

プルリクエストは出してますので、受け入れられたら記事修正しておきます。Godot4以降に対応するスクリプトです。

pluginsとかフォルダを作ってこんな感じにプロジェクトに配置します。

MIDIファイルの読み込みはこんなスクリプトを作ってノードにアタッチします。

extends Node2D

@export_file ("*.mid") var midi_file:String = ""
var parser:MidiFileParser

func _ready() -> void:
	parser = MidiFileParser.load_file(midi_file)

変数に @export_file ("*.拡張子") と書くとGodotエディタ上で特定拡張子のファイルのみ受け付けるようになります。

上のスクリプトをアタッチしたノードのインスペクターから、”Midi File”欄にMIDIファイルをドラッグ・アンド・ドロップしてセット完了です。

このコードだと何も表示されませんが、これでちゃんとMIDIファイルの読み込みはできています。

MIDI情報を取り出す

parser.headerにヘッダ情報、parser.tracksに各トラックの情報がオブジェクトの配列として格納されます。Standard MIDI File規格だと16トラック+メタ情報の17個がtracks配列に格納されます。

パーサの構造

Trackクラスは、Eventクラスを継承したMidiクラス、Metaクラス、Sysexクラスを配列で保持しています。

MidiFileParser
├ header MIDIファイルフォーマット、トラック数
└ tracks
 ├ [0] 主にメタ情報 
 ├ [1] トラック1の情報
 │ ├ events 全イベントの配列(下の全部を含む)
 │ ├ midi ノートだけの配列
 │ ├ meta メタイベントだけの配列
 │ └ sysex システムエクスクルーシブだけの配列
 ├ [2] 
  .  .
  .  .
  .  .

それぞれのイベントがTypeのenumで種類分けされて、時系列順に格納されています。音ゲー制作で重要そうなMIDIイベントは

  • Metaイベント
    • SET_TEMPO → 四分音符の長さ(マイクロ秒)
      • この値で60,000,000を割ればBPMになります。
        (60,000,000 / 500,000 = 120 BPM)
    • 後述するabsolute_ticksをミリ秒に変換する際に使用します。
  • Midiイベント
    • Status.NOTE_ON → ノートON
      • param1 → 音程(0~127)
      • param2 → ベロシティ(0~127)
      • key → キー(C~B)
      • note_name → 音名(C0~G8)
    • Status.NOTE_OFF → ノートオフ
      • 同上
        • 基本的にNOTE_ONとペアになっている
        • ベロシティは0の事が多いが、出力元ソフトで異なる
    • Status.NOTE_AT → キーアフタータッチ
      • param1 → 音程(0~127)
      • param2 → ベロシティ(0~127)
    • Status.PITCH_BEND → ピッチベンド
      • param1、param2の4byteを併せて-8191~+8191
        • 0の場合 param1 = 0, param2 = 64
        • -127の場合 param1 = 1, param2 = 63
        • +127の場合 param1 = 127, param2 = 64
      • このまま扱うのはキツイので変換処理を入れたほうがよいかと思います。
    • Status.CC → コントロールチェンジ
      • param1
        • 1 → モジュレーション
        • 7 → ボリューム
        • 11 → エクスプレッション
      • param2 → param1のCCの値(0~127)

書き出すとキリがないんですが、主要なところでこんなもんですね。MIDI規格準拠なようなのでほぼなんでも取り出せます(ピッチベンドや歌詞など一部バイトコードのままなので要変換)。

MIDIイベントの取り出し方

前述の通り、MIDIイベントはトラック情報に時系列順にすべて格納されています。必要なものだけ取り出すような処理をしていきます。

例えばワンショットのノートだけのシンプルなリズムアクションでしたら、SET_TEMPOとNOTE_ONだけ抽出してもよいでしょう。

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
	var tempo_raw = 500_000 # 初期値はBPM120の値にしておく
	var note_arr:Array[MidiFileParser.Event]
	for i in range(0,2):
		var t = tracks[i]
		for e in t.events:
			match e.event_type:
				MidiFileParser.Event.EventType.META:
					if e.type == MidiFileParser.Meta.Type.SET_TEMPO:
						tempo_raw = e.value
				MidiFileParser.Event.EventType.MIDI:
					if(e.status == MidiFileParser.Midi.Status.NOTE_ON):
						note_arr.append(e)

このコードではメタトラック(トラック0)とトラック1のみ走査して、tempo_rawに四分音符の長さ、note_arr配列にMIDIのノートオンのみを取得しています。あいかわらず読み込むスクリプトしか書いてないので何もみれませんが、デバッガでは読み込んだ内容が確認できます。見づらいですけど。

note_arrの中身のEventオブジェクト。インスペクタをみるとノート名などが格納されているのが確認できます。

range(0, 16)にしたり for t in tracks: にしてあげると全トラックから取得するようにもできますし、if文を追加してピッチベンドやCCも追加することができます。

音ゲーっぽい見た目を作っていく

ここでまたゲーム設計の話に戻ります。

例えばタップラインが引かれていて、バー状のノートが上から降ってくる2Dのゲームだとしましょうか。そのバーをなにかしらの手段で、設定された楽譜のノートオンより数秒早いタイミングで表示させて、楽譜上ジャストのタイミングでタップラインに到達すればよい、という要件になります。

作り方は何パターンもあると思いますが、MIDIを読み込んだ時点で上方向のはるか先にノートのバーを生成しておくのが簡単でしょう。

私がフォークしたこのプラグインでは全イベントのabsolute_ticksというメンバ変数に、トラックのスタートからの総ティック数を保持しています。(このプラグインは今のところ分解能が480固定なので、480ティックで四分音符1個です)

この総ティック数を元にバーを配置してみましょう。

バーのシーンを作ってインスタンス化する

動作確認的な内容ですので、ルートノードにCamera2Dを作り、その中にとりあえずLine2Dでタップラインを引きます。テキトー。

ヒエラルキーはこんな感じです。

次に新規シーンでnote.tscnを作り、こちらもLine2Dで色違いの線を引きます。これもまたテキトー。メインシーンのスクリプトとと同じフォルダに保存してください。

メインシーンにアタッチしているGDスクリプトを修正していきます。

extends Node2D
@export_file ("*.mid") var midi_file:String = ""
@export var camera:Camera2D
@export var tap_line:Line2D
const note_scene = preload("res://note.tscn")
var parser:MidiFileParser
var tempo_raw = 500_000 # 初期値はBPM120の値にしておく

func _ready() -> void:
	parser = MidiFileParser.load_file(midi_file)
	var tracks = parser.tracks
	var note_arr:Array[MidiFileParser.Event]
	for i in range(0,2):
		var t = tracks[i]
		for e in t.events:
			match e.event_type:
				MidiFileParser.Event.EventType.META:
					if e.type == MidiFileParser.Meta.Type.SET_TEMPO:
						tempo_raw = e.value
				MidiFileParser.Event.EventType.MIDI:
					if(e.status == MidiFileParser.Midi.Status.NOTE_ON):
						note_arr.append(e)
						
	# タップラインの位置を取得、ノートをオフセットする
	var tap_line_y = tap_line.position.y
	for note in note_arr:
		var n = note_scene.instantiate()
		n.translate(Vector2((note.param1-60) * 30 + 50 , tap_line_y - note.absolute_ticks))
		add_child(n)

func _process(delta: float) -> void:
	# 四分音符1個の時間 = tempo_raw / 1000000 sec.
	# 四分音符1個の速度 = 480 / sec
	camera.translate(Vector2(0, (-480.0 / (tempo_raw / 1000000.0)) * delta))

エディタでエクスポートされたCamera・Tap Line変数に、先程追加したCamera2DとLine2Dをアタッチしてください。設定できてればこんな感じです。

やってることは

  • タップラインの位置を取得
  • MIDIノートのabsolute_ticksをY軸にしてバーをコピー
  • 毎フレーム BPMに合わせてカメラを上に動かす

これでそれっぽく見せてるだけです。

難易度調整

ちょっと速度が速すぎるような感じがしますね。速度調整出来るようにしてみたいと思います。

これもいろいろと方法はありますが、ticksに係数をかけることで、Y軸を伸縮させるような設定をしてみましょう。

extends Node2D
@export_file ("*.mid") var midi_file:String = ""
@export var camera:Camera2D
@export var tap_line:Line2D
@export var speed:float = 0.5 # 追加
const note_scene = preload("res://note.tscn")
var parser:MidiFileParser
var tempo_raw = 500_000 # 初期値はBPM120の値にしておく

func _ready() -> void:
	parser = MidiFileParser.load_file(midi_file)
	var tracks = parser.tracks
	var note_arr:Array[MidiFileParser.Event]
	for i in range(0,2):
		var t = tracks[i]
		for e in t.events:
			match e.event_type:
				MidiFileParser.Event.EventType.META:
					if e.type == MidiFileParser.Meta.Type.SET_TEMPO:
						tempo_raw = e.value
				MidiFileParser.Event.EventType.MIDI:
					if(e.status == MidiFileParser.Midi.Status.NOTE_ON):
						note_arr.append(e)
						
	# タップラインの位置を取得、ノートをオフセットする
	var tap_line_y = tap_line.position.y
	for note in note_arr:
		var n = note_scene.instantiate()
		n.translate(Vector2((note.param1-60) * 30 + 50 , tap_line_y - note.absolute_ticks * speed)) # 変更
		add_child(n)

func _process(delta: float) -> void:
	# 四分音符1個の時間 = tempo_raw / 1000000 sec.
	# 四分音符1個の速度 = 480 / sec
	camera.translate(Vector2(0, (-480.0 / (tempo_raw / 1000000.0)) * delta * speed)) # 変更

ノートの上下の間隔が詰まり、速度も遅くなったのがおわかりいただけると思います。

課題

速さが違うノートを出したい

カメラを動かすこのやり方だと対応できませんね。

その場合は空のNode2Dノードにnoteシーンをインスタンス化、speedのパラメータをNode2Dごとに変えるなどして、Node2Dノードの方を動かすやり方が有効でしょう。

MIDIの方もトラックを分けると管理しやすいかもしれません。(トラックのBPMはいっしょでOK)

ロングタップ、スライドも出したい

同じトラックでも表現できそうな感じはします。

例えば

  • NOTE_AT(キーボードのアフタータッチ)をロングタップとして追加する
  • PITCH_BENDイベントはスライドとして認識・登録する

などなど。やりごたえありそうなプログラムですね。

コメント

コメントを残す

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