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 を例に説明します。

手順①: スクリプトをプロジェクトに追加

  1. 上の GDScript を res://components/beat_syncer.gd などのパスで保存します。
  2. Godot エディタで再読み込みすると、BeatSyncer がクラスとして認識されます。

手順②: BGMシーンに BeatSyncer をアタッチ

BGM を再生するシンプルなシーン構成例:

BGMPlayer (AudioStreamPlayer)
 └── BeatSyncer (Node)
  1. BGMPlayer(AudioStreamPlayer)をシーンに置き、BGM の AudioStream を設定します。
  2. BGMPlayer の子として Node を追加し、スクリプトに BeatSyncer をアタッチします。
    • 親が AudioStreamPlayer なので、target_player は空のままでOKです。
    • BPM、拍子(beats_per_bar)、細分化(subdivide_per_beat)をインスペクタから設定します。

例: 120 BPM、4/4拍子で、16分音符まで取りたい場合:

  • bpm = 120.0
  • beats_per_bar = 4
  • subdivide_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(欲しい拍だけ抽出)」を分けておくと、
さらに柔軟にコンポーネントを組み合わせていけます。まさに「継承より合成」な設計ですね。