Godotで音ゲーっぽい演出をしようとすると、まず悩むのが「BGMのリズムに合わせて動かす」処理ですよね。
よくある実装としては、
- 各敵や背景ノードがそれぞれ自前でタイマーを持つ
- アニメーション側で「このフレームでジャンプ」みたいに手打ちで合わせる
- オーディオの再生時間を毎フレーム見て if time > next_beat みたいな計算を各所で書く
…といったパターンがありますが、どれも次のような問題が出てきがちです。
- シーンごとに似たようなBPM計算コードがコピペされていく
- BPMやオフセットを変えたいだけなのに、いろんなスクリプトを書き換える必要がある
- 「1小節ごと」「8分音符ごと」など、粒度の違うリズムを扱いたくなるたびにロジックが肥大化
そこで、「リズムに合わせる」部分を 1つのコンポーネントに切り出して、
敵・背景・UI など、どんなノードにもアタッチして使い回せるようにしてしまいましょう。
今回紹介する RhythmSync コンポーネントは、BGMのBPMに合わせてビートごとのシグナルを発行するだけの、シンプルな「リズム発生器」です。
あとはそれを受け取って、各ノード側で「ジャンプする」「色を変える」「スケールを変える」など、好きなリアクションを実装すればOK。
まさに「継承より合成」、リズム同期の責務を1つのノードに閉じ込めて、他はシグナルでつなぐだけの構成ですね。
【Godot 4】BPMで全シーンを踊らせる!「RhythmSync」コンポーネント
フルコード(GDScript / Godot 4)
extends Node
class_name RhythmSync
## BGMのBPMに合わせてビートごとにシグナルを飛ばすコンポーネント。
## - AudioStreamPlayer / AudioStreamPlayer2D / 3D を参照して再生位置を取得
## - BPM・拍子・オフセットを指定して、正確なビートタイミングを計算
## - on_beat / on_sub_beat / on_bar などのシグナルで他ノードに通知
## --- シグナル定義 ---
## 基本ビート(例: 4分音符)ごとに発火
## beat_index: 0,1,2,... とインクリメントされる通し番号
signal beat(beat_index: int)
## サブビート(例: 8分音符・16分音符)ごとに発火
## sub_index: 0,1,2,... とインクリメントされる通し番号
signal sub_beat(sub_index: int)
## 小節の頭で発火(例: 4拍子なら 0,4,8... のビート)
## bar_index: 0,1,2,... とインクリメントされる通し番号
signal bar(bar_index: int)
## 任意のビート倍率で発火(例: 2小節ごとなど)
## multiple: 何ビートごとか(例: 8なら8ビートごと)
signal beat_multiple(multiple: int, beat_index: int)
## --- エクスポート変数 ---
## 参照するオーディオプレイヤー
@export var audio_player: AudioStreamPlayer
## BPM(1分間あたりの拍数)
## 例: 120.0 なら 1秒あたり2拍
@export var bpm: float = 120.0
## 1小節あたりの拍数(拍子)
## 4拍子なら 4、3拍子なら 3
@export_range(1, 16, 1)
var beats_per_bar: int = 4
## 1拍を何分音符とみなすか(標準的には 4 = 4分音符)
## 8にすると「8分の拍子」として扱いたいときなどに利用
@export_enum("Whole:1", "Half:2", "Quarter:4", "Eighth:8", "Sixteenth:16")
var beat_note: int = 4
## サブビートの分解能(何分音符まで刻むか)
## 1: サブビートなし(beat シグナルのみ)
## 2: 8分音符単位(4分音符の半分)
## 4: 16分音符単位(4分音符の1/4)
@export_enum("None:1", "Eighth:2", "Sixteenth:4")
var sub_beat_division: int = 1
## 曲の頭からビート計算を開始するまでのオフセット(秒)
## 曲のイントロが数拍分ある場合などに調整
@export var start_offset_sec: float = 0.0
## 拍のズレを微調整するための補正(秒)
## 解析ツールとGodotの再生タイミングの差を吸収する用途など
@export var fine_tune_offset_sec: float = 0.0
## 自動スタートフラグ
## true の場合、_ready 時に自動で再生開始&ビート検出を有効化
@export var auto_start: bool = true
## 何ビートごとに beat_multiple を飛ばすかのリスト
## 例: [2, 4, 8] なら 2ビートごと / 4ビートごと / 8ビートごとにシグナル発火
@export var multiples: Array[int] = [2, 4, 8]
## デバッグ表示用。true にするとビート検出のログを出力
@export var debug_log: bool = false
## --- 内部状態 ---
var _is_running: bool = false
var _beat_time_sec: float = 0.0 ## 1拍あたりの秒数
var _sub_beat_time_sec: float = 0.0 ## サブビート1つあたりの秒数
var _next_beat_time_sec: float = 0.0
var _next_sub_beat_time_sec: float = 0.0
var _beat_index: int = 0
var _sub_beat_index: int = 0
var _bar_index: int = 0
func _ready() -> void:
## BPMなどからビート長を事前計算しておく
_recalculate_durations()
## 自動開始が有効なら、オーディオ再生と同時に同期を開始
if auto_start:
start()
func _process(delta: float) -> void:
if not _is_running:
return
if audio_player == null:
push_warning("RhythmSync: audio_player が設定されていません。")
return
if audio_player.stream == null:
return
if not audio_player.playing:
return
## 現在の再生位置(秒)を取得
var t: float = audio_player.get_playback_position()
## オフセットを適用
t -= start_offset_sec
t -= fine_tune_offset_sec
if t < 0.0:
## まだビート開始前
return
## --- 基本ビートの検出 ---
while t >= _next_beat_time_sec:
_emit_beat()
_beat_index += 1
_next_beat_time_sec = _beat_index * _beat_time_sec
## --- サブビートの検出 ---
if sub_beat_division > 1:
while t >= _next_sub_beat_time_sec:
_emit_sub_beat()
_sub_beat_index += 1
_next_sub_beat_time_sec = _sub_beat_index * _sub_beat_time_sec
func _recalculate_durations() -> void:
## BPM から 1拍あたりの秒数を計算
## beat_note が 4(4分音符)を基準とする
## 例: bpm=120, beat_note=4 -> 0.5秒/拍
## bpm=120, beat_note=8 -> 0.25秒/拍(8分音符を1拍とみなす)
if bpm <= 0.0:
push_error("RhythmSync: BPM が 0 以下です。正しい値を設定してください。")
bpm = 120.0
var base_beat_sec := 60.0 / bpm
_beat_time_sec = base_beat_sec * (4.0 / float(beat_note))
if sub_beat_division > 1:
_sub_beat_time_sec = _beat_time_sec / float(sub_beat_division)
else:
_sub_beat_time_sec = 0.0
## インデックスと次回発火タイミングをリセット
_beat_index = 0
_sub_beat_index = 0
_bar_index = 0
_next_beat_time_sec = 0.0
_next_sub_beat_time_sec = 0.0
func start() -> void:
## リズム同期の開始。audio_player が未再生ならここで再生。
if audio_player == null:
push_warning("RhythmSync: audio_player が設定されていません。start() は何もしません。")
return
_recalculate_durations()
if not audio_player.playing:
audio_player.play()
_is_running = true
func stop() -> void:
## リズム同期の停止。オーディオ再生は止めない(必要なら呼び出し側で止める)。
_is_running = false
func restart() -> void:
## 再生位置を0に戻してリズム同期をリスタート
if audio_player == null:
return
audio_player.stop()
audio_player.play()
_recalculate_durations()
_is_running = true
func set_bpm(new_bpm: float) -> void:
## ランタイムでBPMを変更したい場合に使用
bpm = new_bpm
_recalculate_durations()
func jump_to_beat(beat_index: int) -> void:
## 指定したビート位置に再生位置をジャンプさせるユーティリティ
## 例: 16ビート目から再開したい、など。
if audio_player == null:
return
var t := float(beat_index) * _beat_time_sec + start_offset_sec + fine_tune_offset_sec
audio_player.play(t)
_recalculate_durations()
## --- シグナル発火の内部処理 ---
func _emit_beat() -> void:
if debug_log:
print("RhythmSync: beat ", _beat_index)
beat.emit(_beat_index)
## 小節頭の判定(beats_per_barごと)
if beats_per_bar > 0 and _beat_index % beats_per_bar == 0:
if debug_log:
print("RhythmSync: bar ", _bar_index)
bar.emit(_bar_index)
_bar_index += 1
## 任意の倍数ビートの発火
for m in multiples:
if m > 0 and _beat_index % m == 0:
if debug_log:
print("RhythmSync: beat_multiple x", m, " at beat ", _beat_index)
beat_multiple.emit(m, _beat_index)
func _emit_sub_beat() -> void:
if debug_log:
print("RhythmSync: sub_beat ", _sub_beat_index)
sub_beat.emit(_sub_beat_index)
使い方の手順
ここからは、実際に「敵をビートに合わせてジャンプさせる」「背景を点滅させる」例で使い方を見ていきましょう。
手順①: RhythmSync をシーンに追加する
まずは、BGMを再生しているシーンに RhythmSync ノードを 1つ置きます。
典型的なステージシーン構成はこんな感じです:
StageRoot (Node2D) ├── BGM (AudioStreamPlayer) ├── RhythmSync (Node) ← このスクリプトをアタッチ ├── Player (CharacterBody2D) ├── EnemySpawner (Node2D) └── Background (Node2D)
- RhythmSync.gd をプロジェクトに保存(上のコードをそのまま貼り付け)
- シーンツリーで
+ ノード追加→Nodeを追加 - そのノードに
RhythmSync.gdをアタッチ - インスペクタの
audio_playerに、BGM用のAudioStreamPlayerをドラッグ&ドロップで指定 - BPM・beats_per_bar・start_offset_sec などを曲に合わせて設定
auto_start = true にしておけば、シーン開始と同時に BGM 再生&リズム同期がスタートします。
手順②: 敵をビートに合わせてジャンプさせる
次に、敵キャラを「ビートごとにぴょんぴょん跳ねる」ようにしてみましょう。
敵シーンの構成例:
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── JumpOnBeat (Node) ← 小さなリアクション用コンポーネント
JumpOnBeat.gd の例:
extends Node
@export var jump_height: float = -200.0
@export var target_body: CharacterBody2D
@export var rhythm_sync: RhythmSync
func _ready() -> void:
if rhythm_sync == null:
## シーンツリーの上位から自動で探す例
rhythm_sync = get_tree().get_first_node_in_group("rhythm_sync") as RhythmSync
if rhythm_sync:
rhythm_sync.beat.connect(_on_beat)
func _on_beat(beat_index: int) -> void:
if target_body == null:
target_body = owner as CharacterBody2D
if target_body == null:
return
## シンプルに上方向の速度を与えるだけ
target_body.velocity.y = jump_height
そして、RhythmSync 側をグループに入れておくと便利です:
StageRoot (Node2D)
├── BGM (AudioStreamPlayer)
├── RhythmSync (Node) [Group: rhythm_sync]
├── Enemy1 (CharacterBody2D)
│ └── JumpOnBeat (Node)
└── Enemy2 (CharacterBody2D)
└── JumpOnBeat (Node)
こうしておくと、1つの RhythmSync がステージ全体のリズムを支配し、敵はそれをシグナルで受け取るだけになります。
継承や複雑な親子関係に頼らず、「リズム同期」という責務を 1コンポーネントに閉じ込めているのがポイントですね。
手順③: 背景をサブビートで点滅させる
今度は、背景を 16分音符ごと(sub_beat) に点滅させてみましょう。
Background (Node2D) ├── ColorRect └── FlashOnSubBeat (Node)
FlashOnSubBeat.gd の例:
extends Node
@export var rhythm_sync: RhythmSync
@export var target: CanvasItem
@export var flash_scale: float = 1.2
@export var normal_scale: float = 1.0
func _ready() -> void:
if rhythm_sync == null:
rhythm_sync = get_tree().get_first_node_in_group("rhythm_sync") as RhythmSync
if target == null:
target = owner as CanvasItem
if rhythm_sync:
rhythm_sync.sub_beat.connect(_on_sub_beat)
func _on_sub_beat(sub_index: int) -> void:
if target == null:
return
## 偶数/奇数のサブビートでスケールを切り替える
if sub_index % 2 == 0:
target.scale = Vector2(flash_scale, flash_scale)
else:
target.scale = Vector2(normal_scale, normal_scale)
RhythmSync 側では、例えば以下のように設定します。
bpm = 128beat_note = 4(4分音符を1拍)sub_beat_division = 4(16分音符まで刻む)
これで、プレイヤーや敵は beat に反応、背景は sub_beat に反応、というふうに「粒度の違うリズム」を同じコンポーネントから受け取れるようになります。
手順④: UI やエフェクトもリズム同期させる
最後に、UI やパーティクルを小節単位でド派手にする例です。
HUD (CanvasLayer) ├── ScoreLabel (Label) └── PulseOnBar (Node)
extends Node
@export var rhythm_sync: RhythmSync
@export var target: CanvasItem
@export var pulse_color: Color = Color.YELLOW
@export var normal_color: Color = Color.WHITE
func _ready() -> void:
if rhythm_sync == null:
rhythm_sync = get_tree().get_first_node_in_group("rhythm_sync") as RhythmSync
if target == null:
target = owner as CanvasItem
if rhythm_sync:
rhythm_sync.bar.connect(_on_bar)
func _on_bar(bar_index: int) -> void:
if target == null:
return
## 小節頭で色を変えて、Tween で元に戻すなど
var tween := create_tween()
target.modulate = pulse_color
tween.tween_property(target, "modulate", normal_color, 0.3)
こうして、プレイヤー・敵・背景・UI など、各レイヤーが それぞれ別のコンポーネントでリズムに反応するようにしておくと、
「この敵だけ 2拍に1回にしたい」「UI のエフェクトはいったんオフにしたい」といった調整もローカルに完結して、とても管理しやすくなります。
メリットと応用
RhythmSync コンポーネントを導入するメリットを整理してみましょう。
- シーン構造がスッキリ
BGM 再生とビート検出のロジックを 1つのノードに閉じ込めることで、
敵や背景のスクリプトから「BPM計算」や「タイマー管理」のコードが消えます。 - 使い回しがしやすい
新しいシーンでも、RhythmSyncノードを1つ置いて BGM を指定するだけで、
既存の「JumpOnBeat」「FlashOnSubBeat」などのコンポーネントがそのまま動きます。 - BPM変更や曲差し替えがラク
BPMやstart_offset_secを変えるのはRhythmSyncだけ。
他のノードはすべてシグナルベースなので、コード修正なしで曲差し替えができます。 - 合成(Composition)で演出を組み立てられる
「ビートに合わせてジャンプ」「サブビートで点滅」「小節頭で爆発」など、
小さなコンポーネントを複数アタッチしていくことで、
継承ツリーを増やさずにリッチな演出を作れます。
応用例としては、
- リズムに合わせて NavMeshAgent の速度を変える
- beat_multiple を使って「2小節ごとにボスがパターン変更」
- 特定の beat_index でだけトラップを起動する「譜面」的な仕組み
など、リズムゲームに限らず「音楽に同期したステージギミック」全般に使えます。
簡単な改造案:特定ビートだけコールバックするヘルパー
「このコンポーネントを使って、特定のビートだけ何かしたい」というケースが多いので、
RhythmSync にこんなヘルパー関数を追加しておくと便利です。
## 指定したビート番号で一度だけコールバックを呼ぶユーティリティ
func call_once_on_beat(target_beat: int, callable: Callable) -> void:
func _handler(beat_index: int) -> void:
if beat_index == target_beat:
## 一度だけ呼び出して、自分自身を切り離す
if callable.is_valid():
callable.call()
beat.disconnect(_handler)
beat.connect(_handler)
使い方:
# 32ビート目でボスを登場させる
rhythm_sync.call_once_on_beat(32, func():
spawn_boss()
)
こういった「小さなユーティリティ」を RhythmSync に足していくと、
「リズムに関することは全部ここを見る」という分かりやすい設計になっていきます。
継承ではなくコンポーネントで責務を分けると、こういう拡張もしやすいですね。
