Godotでリズムゲームっぽい仕組みを作ろうとすると、つい「BGMを再生しているノード」にロジックを全部書いてしまいがちですよね。
AudioStreamPlayer に BPM 計算、ノーツ生成、エフェクト発火…とどんどん責務が積み上がっていくと、あとから別シーンで同じ仕組みを使いたくなったときに地獄を見ます。
さらに、BGM を差し替えたくなったり、BPM を変えたくなったりしたときに、_process() の中で秒数を数えていたり、Timer ノードを都度置き換えていたりすると、管理がどんどんつらくなります。
そこで今回は、「BGMのBPMに同期してシグナルだけ発行する」超シンプルなコンポーネント BeatSyncer を用意して、
「リズムに合わせて何かしたい」側は シグナルを受け取るだけ にしてしまいましょう。
AudioStreamPlayer やプレイヤー、敵、UI などに自由にアタッチできる「合成スタイル」のコンポーネントなので、
継承ツリーをいじらずに、どのシーンからでもリズム同期イベントを使い回せるようになります。
【Godot 4】BPMでゲーム全体をノらせる!「BeatSyncer」コンポーネント
フルコード(GDScript / Godot 4)
extends Node
class_name BeatSyncer
## 親の BGM の BPM に合わせて、一定間隔でシグナルを発行するコンポーネント。
## - 親に AudioStreamPlayer or AudioStreamPlayer2D/3D がいることを想定。
## - BPM や拍子、開始ディレイを調整して、リズムゲームや演出同期に使えます。
## 1拍ごと、あるいはサブビートごとに発火するメインのシグナル
signal beat(beat_index: int, bar_index: int, time_in_song: float)
## 1小節の頭で発火するシグナル
signal bar(bar_index: int, time_in_song: float)
@export_category("Beat Settings")
## 楽曲の BPM(Beats Per Minute)。
## 例: 120.0 なら 1分間に120拍、1拍は0.5秒。
@export_range(1.0, 400.0, 0.1)
var bpm: float = 120.0
## 1小節あたりの拍数(4/4拍子なら4, 3/4拍子なら3 など)
@export_range(1, 16, 1)
var beats_per_bar: int = 4
## 1拍をさらにいくつに分割するか。
## 1: 1拍ごと, 2: 8分音符, 4: 16分音符…のように細かくできます。
@export_range(1, 16, 1)
var subdivide_per_beat: int = 1
## 再生開始から何秒後に同期を開始するか。
## 曲のイントロをスキップしたいときなどに使います。
@export_range(0.0, 30.0, 0.01)
var start_delay_sec: float = 0.0
@export_category("Target Audio")
## デフォルトでは親ノードから AudioStreamPlayer を自動検出しますが、
## 明示的に指定したい場合はここにセットします。
@export var target_player: NodePath
@export_category("Debug")
## デバッグ用ログ出力を有効にするか
@export var debug_log: bool = false
## シミュレーション時に「曲が止まっていても」beat を進めるか。
## 本番では false 推奨。エディタ上の確認用などに。
@export var simulate_without_playing: bool = false
# 内部状態
var _audio_player: AudioStreamPlayer = null
var _seconds_per_subbeat: float = 0.0
var _current_subbeat_index: int = 0 # 0, 1, 2, ...(サブビート単位)
var _current_bar_index: int = 0
var _started: bool = false
func _ready() -> void:
_resolve_audio_player()
_recalculate_timing()
_reset_state()
if debug_log:
print("[BeatSyncer] Ready. bpm=%s, beats_per_bar=%s, subdiv=%s" % [bpm, beats_per_bar, subdivide_per_beat])
func _process(delta: float) -> void:
if _audio_player == null and not simulate_without_playing:
return
# AudioStreamPlayer の再生位置を取得
var t: float = 0.0
var is_playing: bool = false
if _audio_player:
t = _audio_player.get_playback_position()
is_playing = _audio_player.playing
else:
# simulate_without_playing が true のときは、内部時間を進める簡易シミュレーション
t = (Engine.get_physics_frames() / ProjectSettings.get_setting("physics/common/physics_fps")) as float
is_playing = true
if not is_playing and not simulate_without_playing:
return
if t < start_delay_sec:
# まだ同期開始前
return
if not _started:
_started = true
# 最初のサブビート位置を現在時間から計算し直してもよいが、
# シンプルに「ここからカウント開始」でOKにする。
_current_subbeat_index = 0
_current_bar_index = 0
# start_delay_sec を引いた「曲中の有効時間」
var effective_time: float = t - start_delay_sec
# 現在のサブビートインデックスを計算
var total_subbeats_passed: int = int(floor(effective_time / _seconds_per_subbeat))
# 前回から増えたサブビート分だけイベントを発火
while _current_subbeat_index <= total_subbeats_passed:
var subbeat_in_bar: int = _current_subbeat_index % (beats_per_bar * subdivide_per_beat)
var beat_in_bar: int = subbeat_in_bar / subdivide_per_beat
var is_bar_head: bool = (subbeat_in_bar == 0)
# 楽曲開始からの拍インデックス(サブビートではなく「拍」単位)
var beat_index: int = _current_subbeat_index / subdivide_per_beat
# シグナル発火(サブビート単位だが、引数は拍インデックス&小節インデックス)
emit_signal("beat", beat_index, _current_bar_index, t)
if is_bar_head:
emit_signal("bar", _current_bar_index, t)
if debug_log:
print("[BeatSyncer] Bar %s at %.3f sec" % [_current_bar_index, t])
_current_subbeat_index += 1
# 小節インデックス更新
if _current_subbeat_index % (beats_per_bar * subdivide_per_beat) == 0:
_current_bar_index += 1
func _notification(what: int) -> void:
if what == NOTIFICATION_EDITOR_SETTING_CHANGED:
# エディタで BPM 等を変えたときに即反映させる
_recalculate_timing()
func _resolve_audio_player() -> void:
## 対象の AudioStreamPlayer を解決する。
if target_player != NodePath(""):
var node := get_node_or_null(target_player)
if node and node is AudioStreamPlayer:
_audio_player = node
else:
push_warning("[BeatSyncer] target_player is set but not a valid AudioStreamPlayer.")
return
# 明示指定がなければ親から探す
var parent := get_parent()
if parent and parent is AudioStreamPlayer:
_audio_player = parent
else:
# 親が AudioStreamPlayer でない場合、子孫から最初に見つかったものを使う
for child in get_tree().get_nodes_in_group("__beat_syncer_temp__"):
pass # dummy to avoid linter warnings
_audio_player = _find_audio_player_in_parent(parent)
if _audio_player == null:
push_warning("[BeatSyncer] No AudioStreamPlayer found. Set target_player or make parent an AudioStreamPlayer.")
func _find_audio_player_in_parent(node: Node) -> AudioStreamPlayer:
if node == null:
return null
# 親方向に遡って AudioStreamPlayer を探す
var cur := node
while cur:
if cur is AudioStreamPlayer:
return cur
cur = cur.get_parent()
return null
func _recalculate_timing() -> void:
## BPM とサブビート数から、1サブビートあたりの秒数を再計算する。
if bpm <= 0.0:
bpm = 120.0
if beats_per_bar <= 0:
beats_per_bar = 4
if subdivide_per_beat <= 0:
subdivide_per_beat = 1
var seconds_per_beat: float = 60.0 / bpm
_seconds_per_subbeat = seconds_per_beat / subdivide_per_beat
if debug_log:
print("[BeatSyncer] Timing recalculated: _seconds_per_subbeat = ", _seconds_per_subbeat)
func _reset_state() -> void:
_current_subbeat_index = 0
_current_bar_index = 0
_started = false
## 外部から BPM を動的に変更したい場合用のヘルパー
func set_bpm(new_bpm: float) -> void:
bpm = max(1.0, new_bpm)
_recalculate_timing()
_reset_state()
## 外部から「今の曲の途中から同期をやり直したい」ときに使う
func resync_from_current_position() -> void:
_reset_state()
_started = true
if debug_log:
print("[BeatSyncer] Resynced from current playback position.")
使い方の手順
BeatSyncer は「BGMを再生しているノード」にアタッチして使うのが基本です。
ここでは 2D のプレイヤーと、BGM に合わせて点滅する UI を例に説明します。
手順①: スクリプトをプロジェクトに追加
- 上の GDScript を
res://components/beat_syncer.gdなどのパスで保存します。 - Godot エディタで再読み込みすると、BeatSyncer がクラスとして認識されます。
手順②: BGMシーンに BeatSyncer をアタッチ
BGM を再生するシンプルなシーン構成例:
BGMPlayer (AudioStreamPlayer) └── BeatSyncer (Node)
BGMPlayer(AudioStreamPlayer)をシーンに置き、BGM の AudioStream を設定します。BGMPlayerの子としてNodeを追加し、スクリプトにBeatSyncerをアタッチします。- 親が AudioStreamPlayer なので、
target_playerは空のままでOKです。 - BPM、拍子(
beats_per_bar)、細分化(subdivide_per_beat)をインスペクタから設定します。
- 親が AudioStreamPlayer なので、
例: 120 BPM、4/4拍子で、16分音符まで取りたい場合:
bpm = 120.0beats_per_bar = 4subdivide_per_beat = 4(= 16分音符)
手順③: プレイヤーや敵が BeatSyncer のシグナルを受け取る
プレイヤーが BGM の拍に合わせて少しだけジャンプエフェクトを出す例:
MainScene (Node2D)
├── BGMPlayer (AudioStreamPlayer)
│ └── BeatSyncer (Node)
└── Player (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
└── JumpEffect (Node2D)
Player 側のスクリプト例:
extends CharacterBody2D
@onready var jump_effect: Node2D = $JumpEffect
@onready var beat_syncer: BeatSyncer = $"../BGMPlayer/BeatSyncer"
func _ready() -> void:
# BeatSyncer の beat シグナルを受け取る
beat_syncer.beat.connect(_on_beat)
func _on_beat(beat_index: int, bar_index: int, time_in_song: float) -> void:
# 4分音符ごと(=サブビート設定に依存)に軽くエフェクトを出す例
# ここでは単純にスケールを一瞬大きくする
var tween := create_tween()
jump_effect.scale = Vector2.ONE
tween.tween_property(jump_effect, "scale", Vector2(1.2, 1.2), 0.05).set_trans(Tween.TRANS_SINE)
tween.tween_property(jump_effect, "scale", Vector2.ONE, 0.1).set_trans(Tween.TRANS_SINE)
このように、プレイヤー側は「BPM のこと」を一切知らず、
「beat というイベントが来たら何かする」だけに集中できます。
手順④: UI を拍子の頭(小節の最初)だけで点滅させる
今度は UI シーンの例です。
HUD (CanvasLayer) ├── BeatLabel (Label) └── BarFlash (ColorRect)
HUD スクリプトで、同じ BeatSyncer の bar シグナルだけを使います。
extends CanvasLayer
@onready var beat_label: Label = $BeatLabel
@onready var bar_flash: ColorRect = $BarFlash
@onready var beat_syncer: BeatSyncer = $"../BGMPlayer/BeatSyncer"
func _ready() -> void:
beat_syncer.bar.connect(_on_bar)
func _on_bar(bar_index: int, time_in_song: float) -> void:
beat_label.text = "Bar: %d" % bar_index
# 1小節の頭で画面をフラッシュさせる
bar_flash.modulate.a = 0.0
var tween := create_tween()
tween.tween_property(bar_flash, "modulate:a", 0.6, 0.05)
tween.tween_property(bar_flash, "modulate:a", 0.0, 0.2)
同じ BeatSyncer から、プレイヤーと HUD がそれぞれ別のシグナルを受け取る構成になっています。
「BGM の BPM に依存するロジック」は BeatSyncer ひとつに集約されているので、BGM を差し替えても、
BPM を変えても、BeatSyncer の設定を変えるだけで済むのがポイントですね。
メリットと応用
- シーン構造がスッキリ
BGM 再生ノードは「音を鳴らす」ことだけに集中し、リズム同期の責務は BeatSyncer に分離できます。
深い継承や巨大な_process()を避けられるので、後から読んでも迷子になりにくいです。 - どのシーンでも使い回せる
BeatSyncer はただの Node コンポーネントなので、タイトル画面、ゲーム本編、リザルト画面など、
どこにでもポンと置いてシグナルを受け取るだけで「リズムに乗った演出」が作れます。 - ロジックを「合成」できる
プレイヤー、敵、UI、背景エフェクトなど、複数のノードが同じ BeatSyncer を参照し、
それぞれが beat / bar シグナルに反応することで、「継承せずに」リズム同期機能を合成できます。 - BPM変更や楽曲差し替えに強い
BPM を変えたくなったら BeatSyncer のbpmを変えるだけ。
コード側でset_bpm()を呼べば、ゲーム中にテンポチェンジする演出も簡単です。
応用例としては、
- リズムに合わせて敵の攻撃パターンを変える
- ステージギミック(動く床、レーザーなど)を拍に合わせてオンオフする
- カメラシェイクやポストエフェクトを小節の頭で強めにかける
など、「時間ベース」ではなく「音楽ベース」でゲーム全体をドライブする設計がやりやすくなります。
改造案:特定の拍だけをフィルタして通知する
例えば「小節の 1 拍目だけ欲しい」「裏拍だけ欲しい」といったケースでは、
BeatSyncer を直接いじるより、ラッパーコンポーネントをもう1つ作るのがおすすめです。
extends Node
class_name BeatFilter
## 特定の拍だけを外部に通知するコンポーネント。
signal filtered_beat(beat_index: int, bar_index: int, time_in_song: float)
@export var beat_syncer_path: NodePath
## 例: 0 なら 1拍目だけ、[0, 2] なら 1拍目と3拍目だけ、など
@export var target_beats_in_bar: PackedInt32Array = [0]
var _beat_syncer: BeatSyncer
func _ready() -> void:
_beat_syncer = get_node_or_null(beat_syncer_path)
if _beat_syncer == null:
push_warning("[BeatFilter] BeatSyncer not found.")
return
_beat_syncer.beat.connect(_on_beat)
func _on_beat(beat_index: int, bar_index: int, time_in_song: float) -> void:
var beat_in_bar := beat_index % _beat_syncer.beats_per_bar
if beat_in_bar in target_beats_in_bar:
emit_signal("filtered_beat", beat_index, bar_index, time_in_song)
こうして「BeatSyncer(リズムの生データ)」と「BeatFilter(欲しい拍だけ抽出)」を分けておくと、
さらに柔軟にコンポーネントを組み合わせていけます。まさに「継承より合成」な設計ですね。
