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)
  1. RhythmSync.gd をプロジェクトに保存(上のコードをそのまま貼り付け)
  2. シーンツリーで + ノード追加Node を追加
  3. そのノードに RhythmSync.gd をアタッチ
  4. インスペクタの audio_player に、BGM用の AudioStreamPlayer をドラッグ&ドロップで指定
  5. 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 = 128
  • beat_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 に足していくと、
「リズムに関することは全部ここを見る」という分かりやすい設計になっていきます。
継承ではなくコンポーネントで責務を分けると、こういう拡張もしやすいですね。